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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/pages/investments/AssetDetail.tsx
Normal file
122
frontend/src/pages/investments/AssetDetail.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getPriceHistory, getPortfolio } from "@/api/investments";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import Plot from "react-plotly.js";
|
||||
|
||||
export default function AssetDetail() {
|
||||
const { assetId } = useParams<{ assetId: string }>();
|
||||
|
||||
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
|
||||
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
|
||||
|
||||
const { data: prices = [], isLoading } = useQuery({
|
||||
queryKey: ["prices", assetId],
|
||||
queryFn: () => getPriceHistory(assetId!, 365),
|
||||
enabled: !!assetId,
|
||||
});
|
||||
|
||||
const dates = prices.map(p => p.date);
|
||||
const opens = prices.map(p => p.open ?? p.close);
|
||||
const highs = prices.map(p => p.high ?? p.close);
|
||||
const lows = prices.map(p => p.low ?? p.close);
|
||||
const closes = prices.map(p => p.close);
|
||||
const volumes = prices.map(p => p.volume ?? 0);
|
||||
|
||||
const latestPrice = closes[closes.length - 1];
|
||||
const prevPrice = closes[closes.length - 2];
|
||||
const change = latestPrice && prevPrice ? latestPrice - prevPrice : 0;
|
||||
const changePct = prevPrice && prevPrice !== 0 ? (change / prevPrice) * 100 : 0;
|
||||
const isUp = change >= 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/investments" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{holding?.symbol ?? "Asset"}</h1>
|
||||
<p className="text-sm text-muted-foreground">{holding?.asset_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price header */}
|
||||
{latestPrice != null && (
|
||||
<div className="flex items-end gap-4">
|
||||
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
|
||||
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
|
||||
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Your position */}
|
||||
{holding && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Shares held", value: Number(holding.quantity).toLocaleString() },
|
||||
{ label: "Avg cost", value: formatCurrency(holding.avg_cost_basis, holding.currency) },
|
||||
{ label: "Current value", value: holding.current_value != null ? formatCurrency(holding.current_value, holding.currency) : "—" },
|
||||
{ label: "Unrealised gain", value: holding.unrealised_gain != null ? formatCurrency(holding.unrealised_gain, holding.currency) : "—", color: holding.unrealised_gain != null ? (holding.unrealised_gain >= 0 ? "text-success" : "text-destructive") : "" },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={cn("font-semibold tabular-nums", color)}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candlestick chart */}
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Price History (1 Year)</p>
|
||||
{isLoading ? (
|
||||
<div className="h-80 animate-pulse bg-secondary/30 rounded-lg" />
|
||||
) : prices.length === 0 ? (
|
||||
<div className="h-80 flex items-center justify-center text-muted-foreground text-sm">No price data available</div>
|
||||
) : (
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
type: "candlestick",
|
||||
x: dates,
|
||||
open: opens as number[],
|
||||
high: highs as number[],
|
||||
low: lows as number[],
|
||||
close: closes as number[],
|
||||
increasing: { line: { color: "#22c55e" } },
|
||||
decreasing: { line: { color: "#ef4444" } },
|
||||
name: holding?.symbol ?? "Price",
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
x: dates,
|
||||
y: volumes as number[],
|
||||
yaxis: "y2",
|
||||
marker: { color: "rgba(99,102,241,0.3)" },
|
||||
name: "Volume",
|
||||
},
|
||||
]}
|
||||
layout={{
|
||||
paper_bgcolor: "transparent",
|
||||
plot_bgcolor: "transparent",
|
||||
font: { color: "var(--muted-foreground)", size: 11 },
|
||||
xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true },
|
||||
yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] },
|
||||
yaxis2: { domain: [0, 0.2], showgrid: false },
|
||||
margin: { t: 10, r: 10, b: 40, l: 60 },
|
||||
showlegend: false,
|
||||
dragmode: "pan",
|
||||
}}
|
||||
config={{ responsive: true, displayModeBar: false, scrollZoom: true }}
|
||||
style={{ width: "100%", height: "360px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/investments/PortfolioPage.tsx
Normal file
208
frontend/src/pages/investments/PortfolioPage.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPortfolio, deleteHolding } from "@/api/investments";
|
||||
import { getAccounts } from "@/api/accounts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
|
||||
import AddHoldingModal from "./AddHoldingModal";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
|
||||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
||||
];
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const { data: portfolio, isLoading } = useQuery({
|
||||
queryKey: ["portfolio"],
|
||||
queryFn: getPortfolio,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteHolding,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
|
||||
});
|
||||
|
||||
const treemapData = portfolio?.holdings
|
||||
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
|
||||
.map((h, i) => ({
|
||||
name: h.symbol,
|
||||
size: Number(h.current_value ?? h.cost_basis_total),
|
||||
fill: COLORS[i % COLORS.length],
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Investments</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{portfolio ? `${portfolio.holdings.length} holding${portfolio.holdings.length !== 1 ? "s" : ""}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Holding
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{portfolio && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Portfolio Value", value: portfolio.total_value, positive: true },
|
||||
{ label: "Total Cost", value: portfolio.total_cost, positive: true },
|
||||
{ label: "Unrealised Gain", value: portfolio.total_gain, positive: portfolio.total_gain >= 0 },
|
||||
{ label: "Return", value: portfolio.total_gain_pct, positive: portfolio.total_gain_pct >= 0, isPercent: true },
|
||||
].map(({ label, value, positive, isPercent }) => (
|
||||
<div key={label} className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", positive ? "text-success" : "text-destructive")}>
|
||||
{isPercent ? `${Number(value).toFixed(2)}%` : formatCurrency(value, portfolio.currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Treemap */}
|
||||
{treemapData.length > 1 && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(() => {
|
||||
const total = treemapData.reduce((s, d) => s + d.size, 0);
|
||||
return treemapData.map((d, i) => (
|
||||
<div
|
||||
key={d.name}
|
||||
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
|
||||
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
|
||||
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
|
||||
>
|
||||
{d.size / total > 0.06 ? d.name : ""}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 mt-3">
|
||||
{treemapData.map((d, i) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
|
||||
{d.name} — {formatCurrency(d.size, "GBP")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Holdings table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1,2,3].map(i => <div key={i} className="h-16 bg-card border border-border rounded-xl animate-pulse" />)}
|
||||
</div>
|
||||
) : !portfolio || portfolio.holdings.length === 0 ? (
|
||||
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
|
||||
<TrendingUp className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">No holdings yet</p>
|
||||
<p className="text-sm mt-1">Add your first investment holding to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border">
|
||||
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Asset</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Quantity</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Price</th>
|
||||
<th className="text-right px-4 py-3">Value</th>
|
||||
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
|
||||
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
|
||||
<th className="w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portfolio.holdings.map((h) => {
|
||||
const isUp = (h.unrealised_gain ?? 0) >= 0;
|
||||
const change24Up = (h.price_change_24h ?? 0) >= 0;
|
||||
return (
|
||||
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20 transition-colors group">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-bold text-primary">{h.symbol.slice(0,3)}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate">{h.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{h.asset_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden sm:table-cell tabular-nums">
|
||||
{Number(h.quantity).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden md:table-cell tabular-nums">
|
||||
{h.current_price != null ? formatCurrency(h.current_price, h.currency) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold tabular-nums">
|
||||
{h.current_value != null ? formatCurrency(h.current_value, h.currency) : formatCurrency(h.cost_basis_total, h.currency)}
|
||||
</td>
|
||||
<td className={cn("px-4 py-3 text-right hidden lg:table-cell", isUp ? "text-success" : "text-destructive")}>
|
||||
{h.unrealised_gain != null ? (
|
||||
<div>
|
||||
<p className="tabular-nums font-medium">{isUp ? "+" : ""}{formatCurrency(h.unrealised_gain, h.currency)}</p>
|
||||
<p className="text-xs">{isUp ? "+" : ""}{Number(h.unrealised_gain_pct).toFixed(2)}%</p>
|
||||
</div>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className={cn("px-4 py-3 text-right hidden lg:table-cell text-xs", change24Up ? "text-success" : "text-destructive")}>
|
||||
{h.price_change_24h != null ? (
|
||||
<span className="flex items-center justify-end gap-0.5">
|
||||
{change24Up ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
{Number(h.price_change_24h).toFixed(2)}%
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link
|
||||
to={`/investments/${h.asset_id}`}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(h.id)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddHoldingModal
|
||||
accounts={accounts}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue