mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
frontend/src/components/crypto/CryptoHoldingsTable.tsx
Normal file
151
frontend/src/components/crypto/CryptoHoldingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/crypto/CryptoTransactionModal.tsx
Normal file
138
frontend/src/components/crypto/CryptoTransactionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user