mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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
79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|