mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
feat: click a coin row to view its trade history; edit transactions
Matches CoinMarketCap's flow: clicking anywhere on a coin's row in the
holdings table opens its transaction history (previously only a dedicated
icon button did). The table's own History icon is gone since the row now
does that job; the remaining "+"/delete icons get stopPropagation so they
don't also trigger the row click.
Adds the missing piece: transactions could be viewed and deleted but not
edited. New PATCH /crypto/transactions/{id} (partial update, never calls
CoinGecko, same oversell guard as creating a sell) backs a pencil button on
each history row that opens CryptoTransactionModal pre-filled — the same
dialog used for "+", now doing double duty as create/edit.
86/86 backend tests passing (4 new). Verified live against the real API:
create, edit quantity/note, 404 on unknown id, cleanup — real user data
untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
@@ -33,6 +33,10 @@ export function fetchCryptoTransactions(assetId: number) {
|
||||
return api.get<CryptoTransaction[]>(`/crypto/holdings/${assetId}/transactions`);
|
||||
}
|
||||
|
||||
export function updateCryptoTransaction(transactionId: number, input: CryptoTransactionInput) {
|
||||
return api.patch<CryptoHolding>(`/crypto/transactions/${transactionId}`, input);
|
||||
}
|
||||
|
||||
export function deleteCryptoTransaction(transactionId: number) {
|
||||
return api.delete<void>(`/crypto/transactions/${transactionId}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bitcoin, History, Plus, Trash2 } from "lucide-react";
|
||||
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
||||
import { formatCurrency, maskAmount } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
@@ -59,7 +59,11 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
|
||||
const sign = holding.profit_loss !== null && Number(holding.profit_loss) > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<tr key={holding.asset_id}>
|
||||
<tr
|
||||
key={holding.asset_id}
|
||||
onClick={() => onViewHistory(holding)}
|
||||
className="cursor-pointer hover:bg-surface-2"
|
||||
>
|
||||
<td className="py-3 pr-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{holding.thumb_url ? (
|
||||
@@ -120,23 +124,21 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("crypto.form.tradeButton")}
|
||||
onClick={() => onTrade(holding)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
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)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(holding);
|
||||
}}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
|
||||
import { formatCurrency, formatTransactionDate, maskAmount } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
import type { CryptoHolding, CryptoTransaction } from "@/types";
|
||||
|
||||
interface CryptoTransactionHistoryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
holding: CryptoHolding | null;
|
||||
hidden: boolean;
|
||||
onEdit: (transaction: CryptoTransaction) => void;
|
||||
}
|
||||
|
||||
export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden }: CryptoTransactionHistoryModalProps) {
|
||||
export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden, onEdit }: CryptoTransactionHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
|
||||
const deleteTransaction = useDeleteCryptoTransaction();
|
||||
@@ -53,14 +54,24 @@ export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden }
|
||||
{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>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(tx)}
|
||||
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={() => handleDelete(tx.id)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -3,24 +3,30 @@ 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 { useAddCryptoTransaction, useUpdateCryptoTransaction } from "@/hooks/useCrypto";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CryptoHolding, CryptoTransactionType } from "@/types";
|
||||
import type { CryptoHolding, CryptoTransaction, CryptoTransactionType } from "@/types";
|
||||
|
||||
interface CryptoTransactionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
holding: CryptoHolding | null;
|
||||
// Present -> editing that transaction (pre-filled, PATCHes it). Absent ->
|
||||
// recording a new buy/sell against `holding` (the "+" action).
|
||||
transaction?: CryptoTransaction | 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) {
|
||||
* action on the holdings table — or edit/view an existing entry from its
|
||||
* trade history. Never calls CoinGecko either way (see
|
||||
* services/crypto_service.py's add_transaction/update_transaction): value
|
||||
* is recomputed from the last cached price. */
|
||||
export function CryptoTransactionModal({ open, onClose, holding, transaction = null }: CryptoTransactionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const addTransaction = useAddCryptoTransaction();
|
||||
const updateTransaction = useUpdateCryptoTransaction();
|
||||
const isEditing = transaction !== null;
|
||||
|
||||
const [type, setType] = useState<CryptoTransactionType>("buy");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
@@ -31,23 +37,33 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setType("buy");
|
||||
setQuantity("");
|
||||
setPricePerUnit("");
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setNote("");
|
||||
if (transaction) {
|
||||
setType(transaction.type);
|
||||
setQuantity(transaction.quantity);
|
||||
setPricePerUnit(transaction.price_per_unit);
|
||||
setDate(transaction.date);
|
||||
setNote(transaction.note ?? "");
|
||||
} else {
|
||||
setType("buy");
|
||||
setQuantity("");
|
||||
setPricePerUnit("");
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setNote("");
|
||||
}
|
||||
setError(null);
|
||||
}, [open, holding]);
|
||||
}, [open, holding, transaction]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!holding) return;
|
||||
setError(null);
|
||||
const input = { type, quantity, price_per_unit: pricePerUnit, date, note: note || null };
|
||||
try {
|
||||
await addTransaction.mutateAsync({
|
||||
assetId: holding.asset_id,
|
||||
input: { type, quantity, price_per_unit: pricePerUnit, date, note: note || null },
|
||||
});
|
||||
if (transaction) {
|
||||
await updateTransaction.mutateAsync({ transactionId: transaction.id, input });
|
||||
} else {
|
||||
await addTransaction.mutateAsync({ assetId: holding.asset_id, input });
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -58,8 +74,14 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
||||
|
||||
if (!holding) return null;
|
||||
|
||||
const isPending = addTransaction.isPending || updateTransaction.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={t("crypto.form.tradeTitle", { name: holding.name })}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEditing ? t("crypto.form.editTradeTitle", { name: holding.name }) : t("crypto.form.tradeTitle", { name: holding.name })}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@@ -128,8 +150,8 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
||||
<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 type="submit" disabled={isPending}>
|
||||
{isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchCryptoHoldings,
|
||||
fetchCryptoTransactions,
|
||||
refreshCryptoPrices,
|
||||
updateCryptoTransaction,
|
||||
} from "@/api/crypto";
|
||||
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||
|
||||
@@ -61,6 +62,15 @@ export function useAddCryptoTransaction() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCryptoTransaction() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: ({ transactionId, input }: { transactionId: number; input: CryptoTransactionInput }) =>
|
||||
updateCryptoTransaction(transactionId, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCryptoTransaction() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
|
||||
@@ -128,6 +128,7 @@ const ru = {
|
||||
"crypto.form.noteLabel": "Заметка (необязательно)",
|
||||
"crypto.form.addTitle": "Добавить {{name}}",
|
||||
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
||||
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
|
||||
"crypto.form.tradeButton": "Купить / продать",
|
||||
"crypto.form.buy": "Покупка",
|
||||
"crypto.form.sell": "Продажа",
|
||||
@@ -589,6 +590,7 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"crypto.form.noteLabel": "Note (optional)",
|
||||
"crypto.form.addTitle": "Add {{name}}",
|
||||
"crypto.form.tradeTitle": "Trade: {{name}}",
|
||||
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
|
||||
"crypto.form.tradeButton": "Buy / sell",
|
||||
"crypto.form.buy": "Buy",
|
||||
"crypto.form.sell": "Sell",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionMod
|
||||
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
||||
import { getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding, CryptoRange } from "@/types";
|
||||
import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types";
|
||||
|
||||
function formatSyncedAt(iso: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
||||
@@ -33,6 +33,11 @@ export function CryptoPage() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
||||
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||
// Set together with tradingHolding when editing an entry from the history
|
||||
// list — CryptoTransactionModal is the same dialog for both "+" (new
|
||||
// trade, transaction stays null) and this (pre-filled, PATCHes instead of
|
||||
// POSTing). Cleared whenever that dialog closes, same as tradingHolding.
|
||||
const [editingTransaction, setEditingTransaction] = useState<CryptoTransaction | null>(null);
|
||||
|
||||
const holdings = data?.holdings ?? [];
|
||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
||||
@@ -43,6 +48,22 @@ export function CryptoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditTransaction(transaction: CryptoTransaction) {
|
||||
setEditingTransaction(transaction);
|
||||
setTradingHolding(historyHolding);
|
||||
setHistoryHolding(null);
|
||||
}
|
||||
|
||||
function closeTradeModal() {
|
||||
setTradingHolding(null);
|
||||
setEditingTransaction(null);
|
||||
}
|
||||
|
||||
function openTradeModal(holding: CryptoHolding) {
|
||||
setEditingTransaction(null);
|
||||
setTradingHolding(holding);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<CryptoOverviewCard
|
||||
@@ -89,7 +110,7 @@ export function CryptoPage() {
|
||||
<CryptoHoldingsTable
|
||||
items={holdings}
|
||||
hidden={hidden}
|
||||
onTrade={setTradingHolding}
|
||||
onTrade={openTradeModal}
|
||||
onViewHistory={setHistoryHolding}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -99,12 +120,18 @@ export function CryptoPage() {
|
||||
</Card>
|
||||
|
||||
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
<CryptoTransactionModal open={tradingHolding !== null} onClose={() => setTradingHolding(null)} holding={tradingHolding} />
|
||||
<CryptoTransactionModal
|
||||
open={tradingHolding !== null}
|
||||
onClose={closeTradeModal}
|
||||
holding={tradingHolding}
|
||||
transaction={editingTransaction}
|
||||
/>
|
||||
<CryptoTransactionHistoryModal
|
||||
open={historyHolding !== null}
|
||||
onClose={() => setHistoryHolding(null)}
|
||||
holding={historyHolding}
|
||||
hidden={hidden}
|
||||
onEdit={handleEditTransaction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user