- 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>
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|