feat: full CoinMarketCap-style crypto portfolio (buy/sell log, 1h/24h/7d%, P&L)

Replaces the plain quantity field with a CryptoTransaction log (same
"record events, derive the total" shape as Goal/GoalContribution): quantity
and average buy price are now computed via weighted-average cost — a buy
blends into the running average, a sell reduces quantity but leaves the
average cost of what's still held unchanged.

Switches price fetching from /simple/price to CoinGecko's /coins/markets
(still one batched call for every coin), which also returns 1h/24h/7d %
change in the same request. Old PATCH /crypto/holdings/{id} is replaced by
POST .../transactions (buy/sell, rejects selling more than held), GET
.../transactions (history), and DELETE /crypto/transactions/{id}.

Frontend: CryptoHoldingsTable replaces the simple list with a real table
(Name/Price/1h%/24h%/7d%/Holdings/Avg Buy Price/Profit-Loss/Actions),
horizontally scrollable on mobile rather than dropping columns. New
CryptoTransactionModal (buy/sell) and CryptoTransactionHistoryModal.

Backend: 79/79 tests passing (13 in test_crypto.py, up from 9). Also
verified live end-to-end against the real CoinGecko API with the user's own
key — create, buy, sell, oversell rejection, history, manual refresh, all
against real BTC/ETH prices in EUR — test data cleaned up after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 04:25:03 +03:00
parent 7e4508d1de
commit e8cd536584
24 changed files with 1154 additions and 374 deletions

View File

@@ -1,5 +1,12 @@
import { api } from "@/api/client";
import type { CryptoHolding, CryptoHoldingInput, CryptoSearchResult, CryptoSyncResult } from "@/types";
import type {
CryptoHolding,
CryptoHoldingCreateInput,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransaction,
CryptoTransactionInput,
} from "@/types";
// Also triggers the lazy once-a-day auto-refresh server-side — see
// services/crypto_service.py.
@@ -11,16 +18,26 @@ export function refreshCryptoPrices() {
return api.post<CryptoSyncResult>("/crypto/refresh", {});
}
export function createCryptoHolding(input: CryptoHoldingInput) {
export function createCryptoHolding(input: CryptoHoldingCreateInput) {
return api.post<CryptoHolding>("/crypto/holdings", input);
}
export function updateCryptoHoldingQuantity(assetId: number, quantity: string) {
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, { quantity });
// Buy more of, or sell some of, a coin already being tracked.
export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) {
return api.post<CryptoHolding>(`/crypto/holdings/${assetId}/transactions`, input);
}
export function fetchCryptoTransactions(assetId: number) {
return api.get<CryptoTransaction[]>(`/crypto/holdings/${assetId}/transactions`);
}
export function deleteCryptoTransaction(transactionId: number) {
return api.delete<void>(`/crypto/transactions/${transactionId}`);
}
// Reuses the existing asset-delete endpoint — deleting the Asset cascades
// to the linked crypto_holdings row server-side, no dedicated endpoint.
// to the linked crypto_holdings row (and its whole transaction log)
// server-side, no dedicated endpoint.
export function deleteCryptoHolding(assetId: number) {
return api.delete<void>(`/assets/${assetId}`);
}

View File

@@ -26,6 +26,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
const [searchError, setSearchError] = useState<string | null>(null);
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
const [quantity, setQuantity] = useState("");
const [pricePerUnit, setPricePerUnit] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
@@ -34,6 +36,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
setResults([]);
setSelected(null);
setQuantity("");
setPricePerUnit("");
setDate(new Date().toISOString().slice(0, 10));
setSearchError(null);
setSaveError(null);
}, [open]);
@@ -74,6 +78,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
name: selected.name,
thumb_url: selected.thumb_url,
quantity,
price_per_unit: pricePerUnit,
date,
});
onClose();
} catch {
@@ -139,17 +145,42 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
<Input
id="crypto-quantity"
type="number"
step="any"
min="0"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div>
<Label htmlFor="crypto-price">{t("crypto.form.pricePerUnitLabel")}</Label>
<Input
id="crypto-price"
type="number"
step="any"
min="0"
required
value={pricePerUnit}
onChange={(event) => setPricePerUnit(event.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
<Label htmlFor="crypto-date">{t("crypto.form.dateLabel")}</Label>
<Input
id="crypto-quantity"
type="number"
step="any"
min="0"
id="crypto-date"
type="date"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>

View File

@@ -1,75 +0,0 @@
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 { useUpdateCryptoHoldingQuantity } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoEditModalProps {
open: boolean;
onClose: () => void;
holding: CryptoHolding | null;
}
/** Quantity only — editing never calls CoinGecko (see
* services/crypto_service.py's update_holding_quantity): the value is
* recomputed from the last known price instead. */
export function CryptoEditModal({ open, onClose, holding }: CryptoEditModalProps) {
const { t } = useTranslation();
const updateQuantity = useUpdateCryptoHoldingQuantity();
const [quantity, setQuantity] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !holding) return;
setQuantity(holding.quantity);
setError(null);
}, [open, holding]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!holding) return;
setError(null);
try {
await updateQuantity.mutateAsync({ assetId: holding.asset_id, quantity });
onClose();
} catch {
setError(t("crypto.form.saveError"));
}
}
if (!holding) return null;
return (
<Dialog open={open} onClose={onClose} title={t("crypto.form.editTitle", { name: holding.name })}>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<Label htmlFor="crypto-edit-quantity">{t("crypto.form.quantityLabel")}</Label>
<Input
id="crypto-edit-quantity"
type="number"
step="any"
min="0"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
{error && <p className="text-sm text-danger">{error}</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={updateQuantity.isPending}>
{updateQuantity.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</form>
</Dialog>
);
}

View File

@@ -1,64 +0,0 @@
import { Bitcoin, Pencil, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoHoldingsListProps {
items: CryptoHolding[];
onEdit: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void;
}
export function CryptoHoldingsList({ items, onEdit, onDelete }: CryptoHoldingsListProps) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
return (
<ul className="divide-y divide-gridline">
{items.map((holding) => (
<li key={holding.asset_id} className="flex items-center gap-3 py-3">
{holding.thumb_url ? (
<img src={holding.thumb_url} alt="" className="h-9 w-9 shrink-0 rounded-full" />
) : (
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
<Bitcoin size={16} className="text-text-secondary" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
<span className="block truncate text-xs text-text-muted">
{Number(holding.quantity)} {holding.symbol}
{holding.unit_price !== null && ` · ${formatCurrency(holding.unit_price)}`}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block text-sm font-medium tabular-nums text-text-primary">
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
</span>
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
onClick={() => onDelete(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
>
<Trash2 size={15} />
</button>
</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,151 @@
import { Bitcoin, History, Plus, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoHoldingsTableProps {
items: CryptoHolding[];
onTrade: (holding: CryptoHolding) => void;
onViewHistory: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void;
}
function PercentCell({ value }: { value: string | null }) {
if (value === null) return <span className="text-text-muted">—</span>;
const num = Number(value);
const color = num > 0 ? "var(--success)" : num < 0 ? "var(--danger)" : "var(--text-muted)";
return (
<span className="tabular-nums" style={{ color }}>
{num > 0 ? "+" : ""}
{num.toFixed(2)}%
</span>
);
}
export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-text-muted">
<th className="py-2 pr-3 font-medium">{t("crypto.table.name")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.price")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change1h")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change24h")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.holdings")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.profitLoss")}</th>
<th className="py-2 pl-3 text-right font-medium">{t("crypto.table.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gridline">
{items.map((holding) => {
const profitColor =
holding.profit_loss === null
? undefined
: Number(holding.profit_loss) > 0
? "var(--success)"
: Number(holding.profit_loss) < 0
? "var(--danger)"
: "var(--text-muted)";
const sign = holding.profit_loss !== null && Number(holding.profit_loss) > 0 ? "+" : "";
return (
<tr key={holding.asset_id}>
<td className="py-3 pr-3">
<div className="flex items-center gap-2.5">
{holding.thumb_url ? (
<img src={holding.thumb_url} alt="" className="h-7 w-7 shrink-0 rounded-full" />
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-surface-2">
<Bitcoin size={14} className="text-text-secondary" />
</span>
)}
<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>
</div>
</td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
{holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")}
</td>
<td className="py-3 pr-3 text-right">
<PercentCell value={holding.price_change_1h} />
</td>
<td className="py-3 pr-3 text-right">
<PercentCell value={holding.price_change_24h} />
</td>
<td className="py-3 pr-3 text-right">
<PercentCell value={holding.price_change_7d} />
</td>
<td className="py-3 pr-3 text-right">
<span className="block tabular-nums text-text-primary">
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
</span>
<span className="block text-xs tabular-nums text-text-muted">
{Number(holding.quantity)} {holding.symbol}
</span>
</td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
{holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"}
</td>
<td className="py-3 pr-3 text-right">
{holding.profit_loss !== null && holding.profit_loss_percent !== null ? (
<>
<span className="block tabular-nums" style={{ color: profitColor }}>
{sign}
{formatCurrency(holding.profit_loss)}
</span>
<span className="block text-xs tabular-nums" style={{ color: profitColor }}>
{sign}
{holding.profit_loss_percent.toFixed(2)}%
</span>
</>
) : (
<span className="text-text-muted">—</span>
)}
</td>
<td className="py-3 pl-3">
<div className="flex justify-end gap-1">
<button
type="button"
aria-label={t("crypto.form.tradeButton")}
onClick={() => onTrade(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Plus size={15} />
</button>
<button
type="button"
aria-label={t("crypto.history.viewButton")}
onClick={() => onViewHistory(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<History size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
onClick={() => onDelete(holding)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Trash2 } from "lucide-react";
import { Dialog } from "@/components/ui/Dialog";
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
import { formatCurrency, formatTransactionDate } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoTransactionHistoryModalProps {
open: boolean;
onClose: () => void;
holding: CryptoHolding | null;
}
export function CryptoTransactionHistoryModal({ open, onClose, holding }: CryptoTransactionHistoryModalProps) {
const { t } = useTranslation();
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
const deleteTransaction = useDeleteCryptoTransaction();
if (!holding) return null;
function handleDelete(transactionId: number) {
if (window.confirm(t("crypto.history.confirmDelete"))) {
deleteTransaction.mutate(transactionId);
}
}
return (
<Dialog open={open} onClose={onClose} title={t("crypto.history.title", { name: holding.name })}>
{isLoading ? (
<p className="py-6 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : !transactions || transactions.length === 0 ? (
<p className="py-6 text-center text-sm text-text-muted">{t("crypto.history.empty")}</p>
) : (
<ul className="max-h-96 divide-y divide-gridline overflow-y-auto">
{transactions.map((tx) => (
<li key={tx.id} className="flex items-center gap-3 py-2.5">
<span
className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium"
style={{
color: tx.type === "buy" ? "var(--success)" : "var(--danger)",
backgroundColor: tx.type === "buy" ? "color-mix(in srgb, var(--success) 12%, transparent)" : "color-mix(in srgb, var(--danger) 12%, transparent)",
}}
>
{tx.type === "buy" ? t("crypto.form.buy") : t("crypto.form.sell")}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-text-primary">
{Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)}
</span>
<span className="block text-xs text-text-muted">
{formatTransactionDate(tx.date, true)}
{tx.note && ` · ${tx.note}`}
</span>
</span>
<button
type="button"
aria-label={t("common.delete")}
onClick={() => handleDelete(tx.id)}
className="shrink-0 rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
>
<Trash2 size={15} />
</button>
</li>
))}
</ul>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,138 @@
import { type FormEvent, useEffect, useState } from "react";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog";
import { Input, Label } from "@/components/ui/Input";
import { useAddCryptoTransaction } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import type { CryptoHolding, CryptoTransactionType } from "@/types";
interface CryptoTransactionModalProps {
open: boolean;
onClose: () => void;
holding: CryptoHolding | null;
}
/** Buy more of, or sell some of, a coin already being tracked — the "+"
* action on the holdings table. Never calls CoinGecko (see
* services/crypto_service.py's add_transaction): value is recomputed from
* the last cached price. */
export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransactionModalProps) {
const { t } = useTranslation();
const addTransaction = useAddCryptoTransaction();
const [type, setType] = useState<CryptoTransactionType>("buy");
const [quantity, setQuantity] = useState("");
const [pricePerUnit, setPricePerUnit] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setType("buy");
setQuantity("");
setPricePerUnit("");
setDate(new Date().toISOString().slice(0, 10));
setNote("");
setError(null);
}, [open, holding]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!holding) return;
setError(null);
try {
await addTransaction.mutateAsync({
assetId: holding.asset_id,
input: { type, quantity, price_per_unit: pricePerUnit, date, note: note || null },
});
onClose();
} catch (err) {
setError(
err instanceof ApiError && err.status === 400 ? t("crypto.form.errorSellTooMuch") : t("crypto.form.saveError")
);
}
}
if (!holding) return null;
return (
<Dialog open={open} onClose={onClose} title={t("crypto.form.tradeTitle", { name: holding.name })}>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setType("buy")}
className={cn(
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
type === "buy" ? "border-success bg-success/10 text-success" : "border-border text-text-secondary"
)}
>
{t("crypto.form.buy")}
</button>
<button
type="button"
onClick={() => setType("sell")}
className={cn(
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
type === "sell" ? "border-danger bg-danger/10 text-danger" : "border-border text-text-secondary"
)}
>
{t("crypto.form.sell")}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="tx-quantity">{t("crypto.form.quantityLabel")}</Label>
<Input
id="tx-quantity"
type="number"
step="any"
min="0"
required
autoFocus
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div>
<Label htmlFor="tx-price">{t("crypto.form.pricePerUnitLabel")}</Label>
<Input
id="tx-price"
type="number"
step="any"
min="0"
required
value={pricePerUnit}
onChange={(event) => setPricePerUnit(event.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="tx-date">{t("crypto.form.dateLabel")}</Label>
<Input id="tx-date" type="date" required value={date} onChange={(event) => setDate(event.target.value)} />
</div>
<div>
<Label htmlFor="tx-note">{t("crypto.form.noteLabel")}</Label>
<Input id="tx-note" value={note} onChange={(event) => setNote(event.target.value)} />
</div>
{error && <p className="text-sm text-danger">{error}</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={addTransaction.isPending}>
{addTransaction.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</form>
</Dialog>
);
}

View File

@@ -1,20 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addCryptoTransaction,
createCryptoHolding,
deleteCryptoHolding,
deleteCryptoTransaction,
fetchCryptoHoldings,
fetchCryptoTransactions,
refreshCryptoPrices,
searchCryptoCoins,
updateCryptoHoldingQuantity,
} from "@/api/crypto";
import type { CryptoHoldingInput } from "@/types";
import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types";
function useInvalidateCrypto() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
// A crypto holding is a normal Asset under the hood — Net Worth's own
// numbers change the moment one is added/edited/repriced/removed.
// numbers change the moment one is added/traded/repriced/removed.
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
};
}
@@ -23,6 +25,14 @@ export function useCryptoHoldings() {
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
}
export function useCryptoTransactions(assetId: number | null) {
return useQuery({
queryKey: ["crypto-transactions", assetId],
queryFn: () => fetchCryptoTransactions(assetId!),
enabled: assetId !== null,
});
}
export function useRefreshCryptoPrices() {
const invalidate = useInvalidateCrypto();
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
@@ -31,16 +41,24 @@ export function useRefreshCryptoPrices() {
export function useCreateCryptoHolding() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: (input: CryptoHoldingInput) => createCryptoHolding(input),
mutationFn: (input: CryptoHoldingCreateInput) => createCryptoHolding(input),
onSuccess: invalidate,
});
}
export function useUpdateCryptoHoldingQuantity() {
export function useAddCryptoTransaction() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: ({ assetId, quantity }: { assetId: number; quantity: string }) =>
updateCryptoHoldingQuantity(assetId, quantity),
mutationFn: ({ assetId, input }: { assetId: number; input: CryptoTransactionInput }) =>
addCryptoTransaction(assetId, input),
onSuccess: invalidate,
});
}
export function useDeleteCryptoTransaction() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: (transactionId: number) => deleteCryptoTransaction(transactionId),
onSuccess: invalidate,
});
}
@@ -52,8 +70,3 @@ export function useDeleteCryptoHolding() {
onSuccess: invalidate,
});
}
// Not a useQuery — this drives a search-as-you-type picker inside the "add
// holding" modal, called imperatively (debounced) rather than kept in sync
// with a query key.
export { searchCryptoCoins };

View File

@@ -123,9 +123,29 @@ const ru = {
"crypto.search.empty": "Ничего не найдено.",
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Количество",
"crypto.form.pricePerUnitLabel": "Цена за штуку",
"crypto.form.dateLabel": "Дата",
"crypto.form.noteLabel": "Заметка (необязательно)",
"crypto.form.addTitle": "Добавить {{name}}",
"crypto.form.editTitle": "Изменить количество: {{name}}",
"crypto.form.tradeTitle": "Сделка: {{name}}",
"crypto.form.tradeButton": "Купить / продать",
"crypto.form.buy": "Покупка",
"crypto.form.sell": "Продажа",
"crypto.form.errorSellTooMuch": "Нельзя продать больше, чем у вас есть.",
"crypto.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
"crypto.table.name": "Название",
"crypto.table.price": "Цена",
"crypto.table.change1h": "1ч%",
"crypto.table.change24h": "24ч%",
"crypto.table.change7d": "7д%",
"crypto.table.holdings": "Держите",
"crypto.table.avgBuyPrice": "Средняя цена покупки",
"crypto.table.profitLoss": "Прибыль/убыток",
"crypto.table.actions": "Действия",
"crypto.history.title": "История сделок: {{name}}",
"crypto.history.empty": "Пока нет ни одной сделки.",
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
"crypto.history.viewButton": "Смотреть историю",
"roi.calculator.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции",
@@ -551,9 +571,29 @@ const en: Record<keyof typeof ru, string> = {
"crypto.search.empty": "Nothing found.",
"crypto.search.noApiKey": "CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env (free key at coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Quantity",
"crypto.form.pricePerUnitLabel": "Price per unit",
"crypto.form.dateLabel": "Date",
"crypto.form.noteLabel": "Note (optional)",
"crypto.form.addTitle": "Add {{name}}",
"crypto.form.editTitle": "Edit quantity: {{name}}",
"crypto.form.tradeTitle": "Trade: {{name}}",
"crypto.form.tradeButton": "Buy / sell",
"crypto.form.buy": "Buy",
"crypto.form.sell": "Sell",
"crypto.form.errorSellTooMuch": "You can't sell more than you currently hold.",
"crypto.form.saveError": "Failed to save. Check the data and try again.",
"crypto.table.name": "Name",
"crypto.table.price": "Price",
"crypto.table.change1h": "1h%",
"crypto.table.change24h": "24h%",
"crypto.table.change7d": "7d%",
"crypto.table.holdings": "Holdings",
"crypto.table.avgBuyPrice": "Avg. Buy Price",
"crypto.table.profitLoss": "Profit/Loss",
"crypto.table.actions": "Actions",
"crypto.history.title": "Trade history: {{name}}",
"crypto.history.empty": "No trades yet.",
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
"crypto.history.viewButton": "View history",
"roi.calculator.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount",

View File

@@ -3,8 +3,9 @@ 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 { CryptoEditModal } from "@/components/crypto/CryptoEditModal";
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList";
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
import { formatCurrency, getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
@@ -21,7 +22,8 @@ export function CryptoPage() {
const deleteHolding = useDeleteCryptoHolding();
const [addOpen, setAddOpen] = useState(false);
const [editingHolding, setEditingHolding] = useState<CryptoHolding | null>(null);
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
@@ -59,20 +61,32 @@ export function CryptoPage() {
<p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
</div>
<p className="text-xs text-text-muted">
{data?.last_synced_at ? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) }) : t("crypto.neverSynced")}
{data?.last_synced_at
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
: t("crypto.neverSynced")}
</p>
</div>
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
<CryptoHoldingsList items={holdings} onEdit={setEditingHolding} onDelete={handleDelete} />
<CryptoHoldingsTable
items={holdings}
onTrade={setTradingHolding}
onViewHistory={setHistoryHolding}
onDelete={handleDelete}
/>
</>
)}
</CardContent>
</Card>
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
<CryptoEditModal open={editingHolding !== null} onClose={() => setEditingHolding(null)} holding={editingHolding} />
<CryptoTransactionModal open={tradingHolding !== null} onClose={() => setTradingHolding(null)} holding={tradingHolding} />
<CryptoTransactionHistoryModal
open={historyHolding !== null}
onClose={() => setHistoryHolding(null)}
holding={historyHolding}
/>
</div>
);
}

View File

@@ -374,26 +374,59 @@ export interface FinancialAlert {
params: Record<string, number>;
}
export type CryptoTransactionType = "buy" | "sell";
export interface CryptoHolding {
asset_id: number;
coingecko_id: string;
symbol: string;
name: string;
thumb_url: string | null;
// Both derived from the buy/sell log (see CryptoTransaction) — never
// edited directly.
quantity: string;
// null means "added but never priced yet" (CoinGecko was unreachable at
// creation time) — distinct from a real zero value.
avg_buy_price: string | null;
// Cached from the last successful CoinGecko sync — null means "added but
// never priced yet" (CoinGecko was unreachable right at creation),
// distinct from a real zero.
current_price: string | null;
price_change_1h: string | null;
price_change_24h: string | null;
price_change_7d: string | null;
value: string | null;
unit_price: string | null;
as_of_date: string | null;
cost_basis: string | null;
profit_loss: string | null;
profit_loss_percent: number | null;
}
export interface CryptoHoldingInput {
export interface CryptoHoldingCreateInput {
coingecko_id: string;
symbol: string;
name: string;
thumb_url?: string | null;
// A holding always starts with its first buy.
quantity: string;
price_per_unit: string;
date: string;
note?: string | null;
}
export interface CryptoTransaction {
id: number;
asset_id: number;
type: CryptoTransactionType;
quantity: string;
price_per_unit: string;
date: string;
note: string | null;
}
export interface CryptoTransactionInput {
type: CryptoTransactionType;
quantity: string;
price_per_unit: string;
date: string;
note?: string | null;
}
export interface CryptoSyncResult {