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:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View 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>
);
}