mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: add a Crypto tab with live CoinGecko prices synced into Net Worth
Track individual coins (identity + quantity) in a dedicated tab, backed by CoinGecko's Demo API (AURUM_COINGECKO_API_KEY). A holding is just another Asset under the hood (asset_class=crypto) with its value written as a regular AssetValuation — the existing Net Worth aggregation needs zero changes to pick it up. Prices never refresh in the background (no scheduler in this stack, same reasoning as recurring transactions): a "Refresh prices" button forces a sync, and GET /crypto/holdings lazily re-syncs at most once every 24h. CoinGecko outages leave existing values untouched (error_key: "unreachable") instead of failing the request. Backup/restore now carries CryptoHolding rows too, so a restore doesn't silently strip a holding's coin identity. Backend: 75/75 tests passing (9 new), including a real identity-map staleness bug the tests caught in the raw-Core-upsert + ORM re-read pattern, fixed via populate_existing=True. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
@@ -10,6 +10,7 @@ import { AdvicePage } from "@/pages/AdvicePage";
|
||||
import { BudgetPage } from "@/pages/BudgetPage";
|
||||
import { CashFlowPage } from "@/pages/CashFlowPage";
|
||||
import { CategoriesPage } from "@/pages/CategoriesPage";
|
||||
import { CryptoPage } from "@/pages/CryptoPage";
|
||||
import { CsvImportPage } from "@/pages/CsvImportPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { GoalsPage } from "@/pages/GoalsPage";
|
||||
@@ -46,6 +47,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/net-worth" element={<NetWorthPage />} />
|
||||
<Route path="/crypto" element={<CryptoPage />} />
|
||||
<Route path="/roi" element={<RoiPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
|
||||
30
frontend/src/api/crypto.ts
Normal file
30
frontend/src/api/crypto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { CryptoHolding, CryptoHoldingInput, CryptoSearchResult, CryptoSyncResult } 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 refreshCryptoPrices() {
|
||||
return api.post<CryptoSyncResult>("/crypto/refresh", {});
|
||||
}
|
||||
|
||||
export function createCryptoHolding(input: CryptoHoldingInput) {
|
||||
return api.post<CryptoHolding>("/crypto/holdings", input);
|
||||
}
|
||||
|
||||
export function updateCryptoHoldingQuantity(assetId: number, quantity: string) {
|
||||
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, { quantity });
|
||||
}
|
||||
|
||||
// Reuses the existing asset-delete endpoint — deleting the Asset cascades
|
||||
// to the linked crypto_holdings row server-side, no dedicated endpoint.
|
||||
export function deleteCryptoHolding(assetId: number) {
|
||||
return api.delete<void>(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export function searchCryptoCoins(query: string) {
|
||||
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
170
frontend/src/components/crypto/CryptoAddModal.tsx
Normal file
170
frontend/src/components/crypto/CryptoAddModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
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 { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoSearchResult } from "@/types";
|
||||
|
||||
interface CryptoAddModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const { t } = useTranslation();
|
||||
const createHolding = useCreateCryptoHolding();
|
||||
|
||||
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 [quantity, setQuantity] = useState("");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setSelected(null);
|
||||
setQuantity("");
|
||||
setSearchError(null);
|
||||
setSaveError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selected) return;
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setSearchError(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setResults(await searchCryptoCoins(trimmed));
|
||||
} catch (error) {
|
||||
setResults([]);
|
||||
setSearchError(
|
||||
error instanceof ApiError && error.status === 400 ? t("crypto.search.noApiKey") : t("crypto.form.saveError")
|
||||
);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, open, selected, t]);
|
||||
|
||||
async function handleSave(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createHolding.mutateAsync({
|
||||
coingecko_id: selected.coingecko_id,
|
||||
symbol: selected.symbol,
|
||||
name: selected.name,
|
||||
thumb_url: selected.thumb_url,
|
||||
quantity,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
setSaveError(t("crypto.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={selected ? t("crypto.form.addTitle", { name: selected.name }) : t("crypto.addButton")}
|
||||
>
|
||||
{!selected ? (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("crypto.search.placeholder")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{searchError && <p className="text-sm text-danger">{searchError}</p>}
|
||||
{!searchError && query.trim().length < 2 && (
|
||||
<p className="text-sm text-text-muted">{t("crypto.search.hint")}</p>
|
||||
)}
|
||||
{searching && <p className="text-sm text-text-muted">{t("common.loading")}</p>}
|
||||
{!searching && !searchError && query.trim().length >= 2 && results.length === 0 && (
|
||||
<p className="text-sm text-text-muted">{t("crypto.search.empty")}</p>
|
||||
)}
|
||||
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{results.map((coin) => (
|
||||
<li key={coin.coingecko_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(coin)}
|
||||
className="flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-surface-2"
|
||||
>
|
||||
{coin.thumb_url ? (
|
||||
<img src={coin.thumb_url} alt="" className="h-6 w-6 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="h-6 w-6 shrink-0 rounded-full bg-surface-2" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{coin.name}</span>
|
||||
<span className="block text-xs text-text-muted">{coin.symbol}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-surface-2 p-2">
|
||||
{selected.thumb_url ? (
|
||||
<img src={selected.thumb_url} alt="" className="h-6 w-6 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="h-6 w-6 shrink-0 rounded-full bg-surface-1" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{selected.name}</span>
|
||||
<span className="block text-xs text-text-muted">{selected.symbol}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{saveError && <p className="text-sm text-danger">{saveError}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setSelected(null)}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createHolding.isPending}>
|
||||
{createHolding.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/crypto/CryptoEditModal.tsx
Normal file
75
frontend/src/components/crypto/CryptoEditModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/crypto/CryptoHoldingsList.tsx
Normal file
64
frontend/src/components/crypto/CryptoHoldingsList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/hooks/useCrypto.ts
Normal file
59
frontend/src/hooks/useCrypto.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createCryptoHolding,
|
||||
deleteCryptoHolding,
|
||||
fetchCryptoHoldings,
|
||||
refreshCryptoPrices,
|
||||
searchCryptoCoins,
|
||||
updateCryptoHoldingQuantity,
|
||||
} from "@/api/crypto";
|
||||
import type { CryptoHoldingInput } from "@/types";
|
||||
|
||||
function useInvalidateCrypto() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
|
||||
// A crypto holding is a normal Asset under the hood — Net Worth's own
|
||||
// numbers change the moment one is added/edited/repriced/removed.
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCryptoHoldings() {
|
||||
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
||||
}
|
||||
|
||||
export function useRefreshCryptoPrices() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
|
||||
}
|
||||
|
||||
export function useCreateCryptoHolding() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: (input: CryptoHoldingInput) => createCryptoHolding(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCryptoHoldingQuantity() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: ({ assetId, quantity }: { assetId: number; quantity: string }) =>
|
||||
updateCryptoHoldingQuantity(assetId, quantity),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCryptoHolding() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: (assetId: number) => deleteCryptoHolding(assetId),
|
||||
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 };
|
||||
@@ -7,6 +7,7 @@ const STORAGE_KEY = "aurum:language";
|
||||
const ru = {
|
||||
"nav.dashboard": "Дашборд",
|
||||
"nav.netWorth": "Капитал",
|
||||
"nav.crypto": "Криптовалюта",
|
||||
"nav.roi": "Доходность",
|
||||
"nav.transactions": "Транзакции",
|
||||
"nav.accounts": "Счета",
|
||||
@@ -107,6 +108,25 @@ const ru = {
|
||||
"netWorth.assetsTable.asOf": "на {{date}}",
|
||||
"netWorth.assetsTable.annualRoi": "{{percent}}% годовых",
|
||||
|
||||
"crypto.addButton": "Добавить монету",
|
||||
"crypto.refreshButton": "Обновить цены",
|
||||
"crypto.refreshing": "Обновление…",
|
||||
"crypto.lastSynced": "Обновлено {{time}}",
|
||||
"crypto.neverSynced": "Цены ещё не загружались",
|
||||
"crypto.totalValueLabel": "Всего в криптовалюте",
|
||||
"crypto.empty": "Пока нет ни одной монеты. Добавьте первую, чтобы начать отслеживать.",
|
||||
"crypto.pendingPrice": "цена ещё не получена",
|
||||
"crypto.confirmDelete": "Убрать «{{name}}» из отслеживания?",
|
||||
"crypto.syncError.unreachable": "Не удалось подключиться к CoinGecko — показаны последние известные цены.",
|
||||
"crypto.search.placeholder": "Найти монету (например, Bitcoin или BTC)…",
|
||||
"crypto.search.hint": "Начните вводить название или тикер монеты.",
|
||||
"crypto.search.empty": "Ничего не найдено.",
|
||||
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
|
||||
"crypto.form.quantityLabel": "Количество",
|
||||
"crypto.form.addTitle": "Добавить {{name}}",
|
||||
"crypto.form.editTitle": "Изменить количество: {{name}}",
|
||||
"crypto.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"roi.calculator.title": "Калькулятор ROI",
|
||||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||||
"roi.calculator.monthlyIncomeLabel": "Доход в месяц",
|
||||
@@ -415,6 +435,7 @@ const ru = {
|
||||
const en: Record<keyof typeof ru, string> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.netWorth": "Net Worth",
|
||||
"nav.crypto": "Crypto",
|
||||
"nav.roi": "Returns",
|
||||
"nav.transactions": "Transactions",
|
||||
"nav.accounts": "Accounts",
|
||||
@@ -515,6 +536,25 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"netWorth.assetsTable.asOf": "as of {{date}}",
|
||||
"netWorth.assetsTable.annualRoi": "{{percent}}%/year",
|
||||
|
||||
"crypto.addButton": "Add coin",
|
||||
"crypto.refreshButton": "Refresh prices",
|
||||
"crypto.refreshing": "Refreshing…",
|
||||
"crypto.lastSynced": "Updated {{time}}",
|
||||
"crypto.neverSynced": "Prices haven't loaded yet",
|
||||
"crypto.totalValueLabel": "Total in crypto",
|
||||
"crypto.empty": "No coins yet. Add your first one to start tracking.",
|
||||
"crypto.pendingPrice": "price not fetched yet",
|
||||
"crypto.confirmDelete": "Remove \"{{name}}\" from tracking?",
|
||||
"crypto.syncError.unreachable": "Could not reach CoinGecko — showing the last known prices.",
|
||||
"crypto.search.placeholder": "Find a coin (e.g. Bitcoin or BTC)…",
|
||||
"crypto.search.hint": "Start typing a coin's name or ticker.",
|
||||
"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.addTitle": "Add {{name}}",
|
||||
"crypto.form.editTitle": "Edit quantity: {{name}}",
|
||||
"crypto.form.saveError": "Failed to save. Check the data and try again.",
|
||||
|
||||
"roi.calculator.title": "ROI Calculator",
|
||||
"roi.calculator.investmentLabel": "Investment amount",
|
||||
"roi.calculator.monthlyIncomeLabel": "Monthly income",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeftRight,
|
||||
Bitcoin,
|
||||
Calculator,
|
||||
Flag,
|
||||
Layers,
|
||||
@@ -27,6 +28,7 @@ export interface NavItem {
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ labelKey: "nav.dashboard", to: "/", icon: LayoutDashboard },
|
||||
{ labelKey: "nav.netWorth", to: "/net-worth", icon: TrendingUp },
|
||||
{ labelKey: "nav.crypto", to: "/crypto", icon: Bitcoin },
|
||||
{ labelKey: "nav.roi", to: "/roi", icon: Calculator },
|
||||
{ labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight },
|
||||
{ labelKey: "nav.accounts", to: "/accounts", icon: Layers },
|
||||
|
||||
78
frontend/src/pages/CryptoPage.tsx
Normal file
78
frontend/src/pages/CryptoPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { 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 { CryptoEditModal } from "@/components/crypto/CryptoEditModal";
|
||||
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList";
|
||||
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
||||
import { formatCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
|
||||
function formatSyncedAt(iso: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function CryptoPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useCryptoHoldings();
|
||||
const refresh = useRefreshCryptoPrices();
|
||||
const deleteHolding = useDeleteCryptoHolding();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editingHolding, setEditingHolding] = useState<CryptoHolding | null>(null);
|
||||
|
||||
const holdings = data?.holdings ?? [];
|
||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
||||
|
||||
function handleDelete(holding: CryptoHolding) {
|
||||
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
||||
deleteHolding.mutate(holding.asset_id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<CardTitle>{t("nav.crypto")}</CardTitle>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
|
||||
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
{refresh.isPending ? t("crypto.refreshing") : t("crypto.refreshButton")}
|
||||
</Button>
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus size={16} />
|
||||
{t("crypto.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2 border-b border-border pb-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("crypto.totalValueLabel")}</p>
|
||||
<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")}
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
<CryptoEditModal open={editingHolding !== null} onClose={() => setEditingHolding(null)} holding={editingHolding} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -374,6 +374,42 @@ export interface FinancialAlert {
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface CryptoHolding {
|
||||
asset_id: number;
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
thumb_url: string | null;
|
||||
quantity: string;
|
||||
// null means "added but never priced yet" (CoinGecko was unreachable at
|
||||
// creation time) — distinct from a real zero value.
|
||||
value: string | null;
|
||||
unit_price: string | null;
|
||||
as_of_date: string | null;
|
||||
}
|
||||
|
||||
export interface CryptoHoldingInput {
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
thumb_url?: string | null;
|
||||
quantity: string;
|
||||
}
|
||||
|
||||
export interface CryptoSyncResult {
|
||||
synced: boolean;
|
||||
last_synced_at: string | null;
|
||||
error_key: "unreachable" | null;
|
||||
holdings: CryptoHolding[];
|
||||
}
|
||||
|
||||
export interface CryptoSearchResult {
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
thumb_url: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
currency: string;
|
||||
negative_cash_flow_threshold_months: number;
|
||||
|
||||
Reference in New Issue
Block a user