mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +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:
1
DOCS.md
1
DOCS.md
@@ -534,6 +534,7 @@ of the whole request failing.
|
|||||||
| `POST` | `/crypto/holdings` | Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. |
|
| `POST` | `/crypto/holdings` | Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. |
|
||||||
| `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. |
|
| `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. |
|
||||||
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
||||||
|
| `PATCH` | `/crypto/transactions/{transaction_id}` | Edit an existing transaction (partial — send only the fields you're changing). Never calls CoinGecko. `400` if changing a sell's quantity would exceed what the rest of the log leaves held. |
|
||||||
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
|
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
|
||||||
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
|
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
|
||||||
| `GET` | `/crypto/history` | `?range=7d\|30d\|90d\|all` (default `30d`) — total crypto holdings value over time, for the portfolio chart. No `24h` — resolution is only as dense as the sync cadence above. |
|
| `GET` | `/crypto/history` | `?range=7d\|30d\|90d\|all` (default `30d`) — total crypto holdings value over time, for the portfolio chart. No `24h` — resolution is only as dense as the sync cadence above. |
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.schemas.crypto import (
|
|||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
CryptoTransactionCreate,
|
CryptoTransactionCreate,
|
||||||
CryptoTransactionRead,
|
CryptoTransactionRead,
|
||||||
|
CryptoTransactionUpdate,
|
||||||
)
|
)
|
||||||
from app.services.crypto_service import (
|
from app.services.crypto_service import (
|
||||||
CRYPTO_RANGE_DAYS,
|
CRYPTO_RANGE_DAYS,
|
||||||
@@ -20,6 +21,7 @@ from app.services.crypto_service import (
|
|||||||
list_transactions,
|
list_transactions,
|
||||||
refresh_prices,
|
refresh_prices,
|
||||||
search_coins,
|
search_coins,
|
||||||
|
update_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
||||||
@@ -63,6 +65,13 @@ async def list_transactions_route(
|
|||||||
return await list_transactions(session, asset_id)
|
return await list_transactions(session, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/transactions/{transaction_id}", response_model=CryptoHoldingRead)
|
||||||
|
async def update_transaction_route(
|
||||||
|
transaction_id: int, payload: CryptoTransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoHoldingRead:
|
||||||
|
return await update_transaction(session, transaction_id, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/transactions/{transaction_id}", status_code=204)
|
@router.delete("/transactions/{transaction_id}", status_code=204)
|
||||||
async def delete_transaction_route(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
async def delete_transaction_route(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
await delete_transaction(session, transaction_id)
|
await delete_transaction(session, transaction_id)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class CryptoTransactionCreate(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoTransactionUpdate(BaseModel):
|
||||||
|
type: CryptoTransactionType | None = None
|
||||||
|
quantity: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=18)
|
||||||
|
price_per_unit: Decimal | None = Field(default=None, gt=0, max_digits=38, decimal_places=18)
|
||||||
|
date: date_ | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class CryptoTransactionRead(BaseModel):
|
class CryptoTransactionRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from app.schemas.crypto import (
|
|||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
CryptoTransactionCreate,
|
CryptoTransactionCreate,
|
||||||
|
CryptoTransactionUpdate,
|
||||||
)
|
)
|
||||||
from app.services.settings_service import get_or_create_app_settings
|
from app.services.settings_service import get_or_create_app_settings
|
||||||
|
|
||||||
@@ -375,6 +376,44 @@ async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoT
|
|||||||
return _to_read(holding)
|
return _to_read(holding)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_transaction(
|
||||||
|
session: AsyncSession, transaction_id: int, payload: CryptoTransactionUpdate
|
||||||
|
) -> CryptoHoldingRead:
|
||||||
|
"""Edit an existing buy/sell entry — the "view/edit a transaction" flow
|
||||||
|
a plain delete-and-recreate can't offer (you'd lose the original id and
|
||||||
|
any history a future feature might key off it). Never calls CoinGecko,
|
||||||
|
same as add_transaction: value is recomputed from the last cached price."""
|
||||||
|
transaction = await session.get(CryptoTransaction, transaction_id)
|
||||||
|
if transaction is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Crypto transaction not found")
|
||||||
|
asset_id = transaction.asset_id
|
||||||
|
holding = await _get_holding_or_404(session, asset_id)
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
effective_type = updates.get("type", transaction.type)
|
||||||
|
effective_quantity = updates.get("quantity", transaction.quantity)
|
||||||
|
|
||||||
|
if effective_type == CryptoTransactionType.SELL:
|
||||||
|
# Same guard as add_transaction, checked against every *other*
|
||||||
|
# transaction on this holding — editing this one in place must not
|
||||||
|
# let it sell more than what the rest of the log would leave held.
|
||||||
|
others = [t for t in holding.transactions if t.id != transaction_id]
|
||||||
|
quantity_without_this, _ = _compute_position(others)
|
||||||
|
if effective_quantity > quantity_without_this:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot sell more than you currently hold")
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(transaction, field, value)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
quantity, _ = _compute_position(holding.transactions)
|
||||||
|
if holding.last_price is not None:
|
||||||
|
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return _to_read(holding)
|
||||||
|
|
||||||
|
|
||||||
async def list_transactions(session: AsyncSession, asset_id: int) -> list[CryptoTransaction]:
|
async def list_transactions(session: AsyncSession, asset_id: int) -> list[CryptoTransaction]:
|
||||||
await _get_holding_or_404(session, asset_id) # 404s if the holding itself doesn't exist
|
await _get_holding_or_404(session, asset_id) # 404s if the holding itself doesn't exist
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|||||||
@@ -300,3 +300,61 @@ async def test_history_rejects_unknown_range(client: AsyncClient):
|
|||||||
resp = await client.get("/crypto/history?range=24h")
|
resp = await client.get("/crypto/history?range=24h")
|
||||||
|
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_transaction_recomputes_position_without_calling_coingecko(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client, "1", "40000")
|
||||||
|
tx_id = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()[0]["id"]
|
||||||
|
|
||||||
|
async def fail_if_called(coingecko_ids, vs_currency):
|
||||||
|
raise AssertionError("editing a transaction must not call CoinGecko")
|
||||||
|
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", fail_if_called)
|
||||||
|
|
||||||
|
resp = await client.patch(f"/crypto/transactions/{tx_id}", json={"quantity": "2"})
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert money(body["quantity"]) == money("2")
|
||||||
|
assert money(body["value"]) == money("100000") # 2 * 50000 (last known price)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_transaction_edits_are_reflected_in_history(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client, "1", "40000")
|
||||||
|
tx_id = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()[0]["id"]
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/crypto/transactions/{tx_id}", json={"price_per_unit": "45000", "note": "typo fix"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
transactions = (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()
|
||||||
|
assert len(transactions) == 1
|
||||||
|
assert money(transactions[0]["price_per_unit"]) == money("45000")
|
||||||
|
assert transactions[0]["note"] == "typo fix"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_transaction_rejects_editing_a_sell_to_exceed_holdings(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
holding = await _add_bitcoin(client, "2", "40000")
|
||||||
|
await client.post(
|
||||||
|
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||||
|
json={"type": "sell", "quantity": "1", "price_per_unit": "45000", "date": "2026-02-01"},
|
||||||
|
)
|
||||||
|
sell_tx_id = next(
|
||||||
|
t["id"]
|
||||||
|
for t in (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()
|
||||||
|
if t["type"] == "sell"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.patch(f"/crypto/transactions/{sell_tx_id}", json={"quantity": "3"})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
|
||||||
|
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
|
||||||
|
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export function fetchCryptoTransactions(assetId: number) {
|
|||||||
return api.get<CryptoTransaction[]>(`/crypto/holdings/${assetId}/transactions`);
|
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) {
|
export function deleteCryptoTransaction(transactionId: number) {
|
||||||
return api.delete<void>(`/crypto/transactions/${transactionId}`);
|
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 { formatCurrency, maskAmount } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding } from "@/types";
|
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 ? "+" : "";
|
const sign = holding.profit_loss !== null && Number(holding.profit_loss) > 0 ? "+" : "";
|
||||||
|
|
||||||
return (
|
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">
|
<td className="py-3 pr-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{holding.thumb_url ? (
|
{holding.thumb_url ? (
|
||||||
@@ -120,23 +124,21 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("crypto.form.tradeButton")}
|
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"
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
>
|
>
|
||||||
<Plus size={15} />
|
<Plus size={15} />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("common.delete")}
|
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"
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<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 { Dialog } from "@/components/ui/Dialog";
|
||||||
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
|
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
|
||||||
import { formatCurrency, formatTransactionDate, maskAmount } from "@/lib/format";
|
import { formatCurrency, formatTransactionDate, maskAmount } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding } from "@/types";
|
import type { CryptoHolding, CryptoTransaction } from "@/types";
|
||||||
|
|
||||||
interface CryptoTransactionHistoryModalProps {
|
interface CryptoTransactionHistoryModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
holding: CryptoHolding | null;
|
holding: CryptoHolding | null;
|
||||||
hidden: boolean;
|
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 { t } = useTranslation();
|
||||||
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
|
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
|
||||||
const deleteTransaction = useDeleteCryptoTransaction();
|
const deleteTransaction = useDeleteCryptoTransaction();
|
||||||
@@ -53,14 +54,24 @@ export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden }
|
|||||||
{tx.note && ` · ${tx.note}`}
|
{tx.note && ` · ${tx.note}`}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("common.delete")}
|
aria-label={t("common.delete")}
|
||||||
onClick={() => handleDelete(tx.id)}
|
onClick={() => handleDelete(tx.id)}
|
||||||
className="shrink-0 rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,24 +3,30 @@ import { ApiError } from "@/api/client";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Dialog } from "@/components/ui/Dialog";
|
import { Dialog } from "@/components/ui/Dialog";
|
||||||
import { Input, Label } from "@/components/ui/Input";
|
import { Input, Label } from "@/components/ui/Input";
|
||||||
import { useAddCryptoTransaction } from "@/hooks/useCrypto";
|
import { useAddCryptoTransaction, useUpdateCryptoTransaction } from "@/hooks/useCrypto";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CryptoHolding, CryptoTransactionType } from "@/types";
|
import type { CryptoHolding, CryptoTransaction, CryptoTransactionType } from "@/types";
|
||||||
|
|
||||||
interface CryptoTransactionModalProps {
|
interface CryptoTransactionModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
holding: CryptoHolding | null;
|
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 "+"
|
/** Buy more of, or sell some of, a coin already being tracked — the "+"
|
||||||
* action on the holdings table. Never calls CoinGecko (see
|
* action on the holdings table — or edit/view an existing entry from its
|
||||||
* services/crypto_service.py's add_transaction): value is recomputed from
|
* trade history. Never calls CoinGecko either way (see
|
||||||
* the last cached price. */
|
* services/crypto_service.py's add_transaction/update_transaction): value
|
||||||
export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransactionModalProps) {
|
* is recomputed from the last cached price. */
|
||||||
|
export function CryptoTransactionModal({ open, onClose, holding, transaction = null }: CryptoTransactionModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const addTransaction = useAddCryptoTransaction();
|
const addTransaction = useAddCryptoTransaction();
|
||||||
|
const updateTransaction = useUpdateCryptoTransaction();
|
||||||
|
const isEditing = transaction !== null;
|
||||||
|
|
||||||
const [type, setType] = useState<CryptoTransactionType>("buy");
|
const [type, setType] = useState<CryptoTransactionType>("buy");
|
||||||
const [quantity, setQuantity] = useState("");
|
const [quantity, setQuantity] = useState("");
|
||||||
@@ -31,23 +37,33 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
if (transaction) {
|
||||||
|
setType(transaction.type);
|
||||||
|
setQuantity(transaction.quantity);
|
||||||
|
setPricePerUnit(transaction.price_per_unit);
|
||||||
|
setDate(transaction.date);
|
||||||
|
setNote(transaction.note ?? "");
|
||||||
|
} else {
|
||||||
setType("buy");
|
setType("buy");
|
||||||
setQuantity("");
|
setQuantity("");
|
||||||
setPricePerUnit("");
|
setPricePerUnit("");
|
||||||
setDate(new Date().toISOString().slice(0, 10));
|
setDate(new Date().toISOString().slice(0, 10));
|
||||||
setNote("");
|
setNote("");
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [open, holding]);
|
}, [open, holding, transaction]);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!holding) return;
|
if (!holding) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const input = { type, quantity, price_per_unit: pricePerUnit, date, note: note || null };
|
||||||
try {
|
try {
|
||||||
await addTransaction.mutateAsync({
|
if (transaction) {
|
||||||
assetId: holding.asset_id,
|
await updateTransaction.mutateAsync({ transactionId: transaction.id, input });
|
||||||
input: { type, quantity, price_per_unit: pricePerUnit, date, note: note || null },
|
} else {
|
||||||
});
|
await addTransaction.mutateAsync({ assetId: holding.asset_id, input });
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -58,8 +74,14 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
|||||||
|
|
||||||
if (!holding) return null;
|
if (!holding) return null;
|
||||||
|
|
||||||
|
const isPending = addTransaction.isPending || updateTransaction.isPending;
|
||||||
|
|
||||||
return (
|
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">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -128,8 +150,8 @@ export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransac
|
|||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={addTransaction.isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{addTransaction.isPending ? t("common.saving") : t("common.save")}
|
{isPending ? t("common.saving") : t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
fetchCryptoHoldings,
|
fetchCryptoHoldings,
|
||||||
fetchCryptoTransactions,
|
fetchCryptoTransactions,
|
||||||
refreshCryptoPrices,
|
refreshCryptoPrices,
|
||||||
|
updateCryptoTransaction,
|
||||||
} from "@/api/crypto";
|
} from "@/api/crypto";
|
||||||
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
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() {
|
export function useDeleteCryptoTransaction() {
|
||||||
const invalidate = useInvalidateCrypto();
|
const invalidate = useInvalidateCrypto();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const ru = {
|
|||||||
"crypto.form.noteLabel": "Заметка (необязательно)",
|
"crypto.form.noteLabel": "Заметка (необязательно)",
|
||||||
"crypto.form.addTitle": "Добавить {{name}}",
|
"crypto.form.addTitle": "Добавить {{name}}",
|
||||||
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
||||||
|
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
|
||||||
"crypto.form.tradeButton": "Купить / продать",
|
"crypto.form.tradeButton": "Купить / продать",
|
||||||
"crypto.form.buy": "Покупка",
|
"crypto.form.buy": "Покупка",
|
||||||
"crypto.form.sell": "Продажа",
|
"crypto.form.sell": "Продажа",
|
||||||
@@ -589,6 +590,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.form.noteLabel": "Note (optional)",
|
"crypto.form.noteLabel": "Note (optional)",
|
||||||
"crypto.form.addTitle": "Add {{name}}",
|
"crypto.form.addTitle": "Add {{name}}",
|
||||||
"crypto.form.tradeTitle": "Trade: {{name}}",
|
"crypto.form.tradeTitle": "Trade: {{name}}",
|
||||||
|
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
|
||||||
"crypto.form.tradeButton": "Buy / sell",
|
"crypto.form.tradeButton": "Buy / sell",
|
||||||
"crypto.form.buy": "Buy",
|
"crypto.form.buy": "Buy",
|
||||||
"crypto.form.sell": "Sell",
|
"crypto.form.sell": "Sell",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionMod
|
|||||||
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
||||||
import { getIntlLocale } from "@/lib/format";
|
import { getIntlLocale } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding, CryptoRange } from "@/types";
|
import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types";
|
||||||
|
|
||||||
function formatSyncedAt(iso: string): string {
|
function formatSyncedAt(iso: string): string {
|
||||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
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 [addOpen, setAddOpen] = useState(false);
|
||||||
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
||||||
const [historyHolding, setHistoryHolding] = 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 holdings = data?.holdings ?? [];
|
||||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<CryptoOverviewCard
|
<CryptoOverviewCard
|
||||||
@@ -89,7 +110,7 @@ export function CryptoPage() {
|
|||||||
<CryptoHoldingsTable
|
<CryptoHoldingsTable
|
||||||
items={holdings}
|
items={holdings}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
onTrade={setTradingHolding}
|
onTrade={openTradeModal}
|
||||||
onViewHistory={setHistoryHolding}
|
onViewHistory={setHistoryHolding}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
@@ -99,12 +120,18 @@ export function CryptoPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
<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
|
<CryptoTransactionHistoryModal
|
||||||
open={historyHolding !== null}
|
open={historyHolding !== null}
|
||||||
onClose={() => setHistoryHolding(null)}
|
onClose={() => setHistoryHolding(null)}
|
||||||
holding={historyHolding}
|
holding={historyHolding}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
|
onEdit={handleEditTransaction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user