Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
208
frontend/src/pages/investments/AddHoldingModal.tsx
Normal file
208
frontend/src/pages/investments/AddHoldingModal.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { X, Search, Loader2 } from "lucide-react";
|
||||
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
|
||||
import { format } from "date-fns";
|
||||
|
||||
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 [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 [form, setForm] = useState({
|
||||
account_id: investAccounts[0]?.id ?? "",
|
||||
quantity: "",
|
||||
price: "",
|
||||
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 price = parseFloat(form.price);
|
||||
const holding = await createHolding({
|
||||
account_id: form.account_id,
|
||||
asset_id: selected.id,
|
||||
quantity: qty,
|
||||
avg_cost_basis: price,
|
||||
currency: selected.currency,
|
||||
});
|
||||
await addInvestmentTransaction({
|
||||
holding_id: holding.id,
|
||||
type: "buy",
|
||||
quantity: qty,
|
||||
price: price,
|
||||
fees: parseFloat(form.fees) || 0,
|
||||
currency: selected.currency,
|
||||
date: form.date,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.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>
|
||||
<label className="text-xs font-medium block mb-1">Price paid *</label>
|
||||
<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="150.00"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue