MyMidas/frontend/src/pages/investments/AddHoldingModal.tsx
megaproxy 312594f3d2 Include investment holding values in net worth and account balances
- Net worth report, balance sheet, and daily snapshots now add holding
  market values (falling back to cost basis) to investment-type account
  balances (investment, pension, stocks_shares_isa, crypto_wallet)
- Accounts list shows total value for investment accounts with a
  breakdown line ("£X cash + £Y holdings") when both are non-zero
- Add Holding modal gains a "Debit account for this purchase" toggle
  that creates a matching withdrawal transaction, enabling proper cash
  flow tracking for users who fund their brokerage via transfer first
- Both simple (holdings-only) and full cash-flow workflows produce
  correct net worth figures without double-counting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:10:19 +00:00

293 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { createTransaction } from "@/api/transactions";
import { useUiStore } from "@/store/uiStore";
import { format } from "date-fns";
const COMMON_CURRENCIES = ["GBP", "USD", "EUR", "JPY", "CAD", "AUD", "CHF"];
interface Account { id: string; name: string; type: string; }
interface Props {
accounts: Account[];
onClose: () => void;
onSuccess: () => void;
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const baseCurrency = useUiStore(s => s.currency);
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<AssetSearchResult | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const investAccounts = accounts.filter(a =>
["investment", "pension", "savings", "other"].includes(a.type)
);
const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
const [recordCash, setRecordCash] = useState(false);
const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
currency: baseCurrency,
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
useEffect(() => {
const t = setTimeout(async () => {
if (query.length < 1) { setResults([]); return; }
setSearching(true);
try {
const r = await searchAssets(query);
setResults(r);
} finally {
setSearching(false);
}
}, 400);
return () => clearTimeout(t);
}, [query]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected || !form.account_id || !form.quantity || !form.price) return;
setSaving(true);
setError(null);
try {
const qty = parseFloat(form.quantity);
const rawPrice = parseFloat(form.price);
const price = priceMode === "total" ? rawPrice / qty : rawPrice;
const holding = await createHolding({
account_id: form.account_id,
asset_id: selected.id,
quantity: 0,
avg_cost_basis: 0,
currency: form.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
type: "buy",
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: form.currency,
date: form.date,
});
if (recordCash) {
const totalSpent = qty * price + (parseFloat(form.fees) || 0);
await createTransaction({
account_id: form.account_id,
type: "investment",
amount: -totalSpent,
currency: form.currency,
date: form.date,
description: `Buy ${selected.symbol} × ${qty}`,
status: "cleared",
});
}
onSuccess();
} catch (e: any) {
const detail = e?.response?.data?.detail;
setError(Array.isArray(detail) ? detail.map((d: any) => d.msg).join(", ") : (detail ?? "Failed to add holding"));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">Add Holding</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
{/* Asset search */}
<div>
<label className="text-sm font-medium block mb-1.5">Search asset *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setSelected(null); }}
placeholder="e.g. AAPL, Vanguard, BTC..."
className="w-full pl-9 pr-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{searching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />}
</div>
{results.length > 0 && !selected && (
<div className="mt-1 border border-border rounded-lg overflow-hidden shadow-lg bg-card">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => { setSelected(r); setResults([]); setQuery(`${r.symbol}${r.name}`); }}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-secondary transition-colors text-left"
>
<div>
<span className="font-semibold text-sm">{r.symbol}</span>
<span className="text-muted-foreground text-sm ml-2">{r.name}</span>
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground">{r.type} · {r.currency}</span>
{r.last_price && <p className="text-xs font-medium">{r.last_price}</p>}
</div>
</button>
))}
</div>
)}
{selected && (
<p className="text-xs text-success mt-1"> Selected: {selected.symbol} ({selected.name})</p>
)}
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select
value={form.account_id}
onChange={(e) => setForm(f => ({ ...f, account_id: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{investAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
{investAccounts.length === 0 && <option value="">No investment accounts add one first</option>}
</select>
</div>
{/* Quantity / Price / Fees */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium block mb-1">Quantity *</label>
<input
type="number" min="0" step="any"
value={form.quantity}
onChange={(e) => setForm(f => ({ ...f, quantity: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="10"
/>
</div>
<div className="col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium">
{priceMode === "per_share" ? "Price per share *" : "Total price paid *"}
</label>
<div className="flex rounded-md border border-input overflow-hidden text-xs">
<button
type="button"
onClick={() => setPriceMode("per_share")}
className={`px-2 py-0.5 transition-colors ${priceMode === "per_share" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
>
Per share
</button>
<button
type="button"
onClick={() => setPriceMode("total")}
className={`px-2 py-0.5 transition-colors ${priceMode === "total" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
>
Total
</button>
</div>
</div>
<div className="flex gap-1">
<select
value={form.currency}
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
className="rounded-md border border-input bg-background px-1.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={priceMode === "per_share" ? "150.00" : "1500.00"}
/>
</div>
{form.price && form.quantity && parseFloat(form.quantity) > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{priceMode === "per_share"
? `Total: ${form.currency} ${(parseFloat(form.price) * parseFloat(form.quantity)).toFixed(2)}`
: `Per share: ${form.currency} ${(parseFloat(form.price) / parseFloat(form.quantity)).toFixed(4)}`
}
</p>
)}
</div>
</div>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
<input
type="number" min="0" step="any"
value={form.fees}
onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0"
/>
</div>
{selected && form.currency !== selected.currency && (
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
Price in <strong>{form.currency}</strong> asset trades in <strong>{selected.currency}</strong>. Current values will be converted using live exchange rates.
</p>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm(f => ({ ...f, date: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Cash purchase toggle */}
<label className="flex items-start gap-3 cursor-pointer select-none p-3 rounded-lg border border-border hover:bg-secondary/40 transition-colors">
<div className="relative mt-0.5 shrink-0">
<input
type="checkbox"
checked={recordCash}
onChange={(e) => setRecordCash(e.target.checked)}
className="sr-only"
/>
<div className={`w-9 h-5 rounded-full transition-colors ${recordCash ? "bg-primary" : "bg-secondary border border-input"}`}>
<div className={`w-3.5 h-3.5 rounded-full bg-white shadow transition-transform mt-0.5 ${recordCash ? "translate-x-4.5 ml-0.5" : "translate-x-0.5 ml-0.5"}`} />
</div>
</div>
<div>
<p className="text-sm font-medium">Debit account for this purchase</p>
<p className="text-xs text-muted-foreground mt-0.5">Records a matching withdrawal from the account. Enable this if you transferred cash to this account first and want to track the balance accurately.</p>
</div>
</label>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? "Adding…" : "Add Holding"}
</button>
</div>
</div>
</div>
</div>
);
}