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:
ZProger
2026-08-30 05:06:20 +03:00
parent dc9421e08a
commit c3309b9d60
12 changed files with 238 additions and 45 deletions

View File

@@ -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}`);
}

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View File

@@ -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",

View File

@@ -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>
);