Investment portfolio charts, search fix, and holding creation fixes
- Add four portfolio charts: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar, return % bar - Fix asset search to use yf.Search() full-text instead of ticker-only lookup — name searches like "vanguard ftse all world" now work - Fix holding creation double-quantity bug: holdings now created with quantity=0 so buy transaction is sole source of quantity/cost basis - Add per-share / total price toggle in Add Holding modal with live calculated equivalent shown as you type - Add ErrorBoundary in AppShell so render errors show a message instead of a blank page - Fix donut charts using || instead of ?? when falling back from current_value to cost_basis_total (0 was not falling through ??) - Allow HoldingCreate.quantity >= 0 (was gt=0) to support zero-init - Fix error display for Pydantic v2 array-of-objects validation errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26e2a055db
commit
cdc1e67321
9 changed files with 424 additions and 70 deletions
37
frontend/src/components/ErrorBoundary.tsx
Normal file
37
frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Component, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Props { children: ReactNode; }
|
||||
interface State { error: Error | null; }
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">Something went wrong</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 font-mono">{this.state.error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { this.setState({ error: null }); window.location.reload(); }}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useUiStore } from "@/store/uiStore";
|
|||
import Sidebar from "./Sidebar";
|
||||
import TopBar from "./TopBar";
|
||||
import MobileNav from "./MobileNav";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -25,7 +26,7 @@ export default function AppShell({ children }: AppShellProps) {
|
|||
<TopBar />
|
||||
{/* Extra bottom padding on mobile so content clears the nav bar */}
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
|
||||
{children}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
["investment", "pension", "savings", "other"].includes(a.type)
|
||||
);
|
||||
|
||||
const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
|
||||
const [form, setForm] = useState({
|
||||
account_id: investAccounts[0]?.id ?? "",
|
||||
quantity: "",
|
||||
|
|
@ -57,12 +58,13 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
setError(null);
|
||||
try {
|
||||
const qty = parseFloat(form.quantity);
|
||||
const price = parseFloat(form.price);
|
||||
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: qty,
|
||||
avg_cost_basis: price,
|
||||
quantity: 0,
|
||||
avg_cost_basis: 0,
|
||||
currency: form.currency,
|
||||
});
|
||||
await addInvestmentTransaction({
|
||||
|
|
@ -76,7 +78,8 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail ?? "Failed to add holding");
|
||||
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);
|
||||
}
|
||||
|
|
@ -159,8 +162,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Price paid *</label>
|
||||
<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}
|
||||
|
|
@ -174,20 +197,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
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"
|
||||
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>
|
||||
<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-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 && (
|
||||
|
|
@ -213,6 +244,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
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"
|
||||
|
|
|
|||
295
frontend/src/pages/investments/PortfolioCharts.tsx
Normal file
295
frontend/src/pages/investments/PortfolioCharts.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import {
|
||||
PieChart, Pie, Cell, Tooltip, ResponsiveContainer,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, ReferenceLine, LabelList,
|
||||
} from "recharts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import type { PortfolioSummary, HoldingResponse } from "@/api/investments";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
|
||||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
stock: "#6366f1",
|
||||
etf: "#22c55e",
|
||||
crypto: "#f97316",
|
||||
fund: "#14b8a6",
|
||||
bond: "#f59e0b",
|
||||
other: "#94a3b8",
|
||||
};
|
||||
|
||||
function typeColor(type: string) {
|
||||
return TYPE_COLORS[type.toLowerCase()] ?? TYPE_COLORS.other;
|
||||
}
|
||||
|
||||
function DonutTooltip({ active, payload, currency }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0];
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg px-3 py-2 text-xs shadow-lg">
|
||||
<p className="font-semibold">{d.name}</p>
|
||||
<p className="text-muted-foreground">{formatCurrency(d.value, currency)}</p>
|
||||
<p className="text-muted-foreground">{d.payload.pct?.toFixed(1)}%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Donut: allocation by holding ─────────────────────────────────────────────
|
||||
|
||||
export function AllocationDonut({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const val = (h: HoldingResponse) => Number(h.current_value || h.cost_basis_total);
|
||||
const total = portfolio.holdings.reduce((s, h) => s + val(h), 0);
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => val(h) > 0)
|
||||
.map((h, i) => ({
|
||||
name: h.symbol,
|
||||
value: val(h),
|
||||
pct: (val(h) / total) * 100,
|
||||
fill: COLORS[i % COLORS.length],
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation by Holding</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="55%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.fill} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
|
||||
{data.map((d) => (
|
||||
<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: d.fill }} />
|
||||
<span>{d.name}</span>
|
||||
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Donut: allocation by asset type ──────────────────────────────────────────
|
||||
|
||||
export function AssetTypeDonut({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const byType: Record<string, number> = {};
|
||||
for (const h of portfolio.holdings) {
|
||||
const t = (h.asset_type || "other").toLowerCase();
|
||||
byType[t] = (byType[t] ?? 0) + Number(h.current_value || h.cost_basis_total);
|
||||
}
|
||||
const total = Object.values(byType).reduce((s, v) => s + v, 0);
|
||||
const data = Object.entries(byType)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([type, value]) => ({
|
||||
name: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
value,
|
||||
pct: (value / total) * 100,
|
||||
fill: typeColor(type),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation by Asset Type</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="55%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.fill} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
|
||||
{data.map((d) => (
|
||||
<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: d.fill }} />
|
||||
<span>{d.name}</span>
|
||||
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bar: cost basis vs current value ─────────────────────────────────────────
|
||||
|
||||
function CurrencyYAxis({ value, currency }: { value: number; currency: string }) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
|
||||
return formatCurrency(value, currency);
|
||||
}
|
||||
|
||||
export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => h.cost_basis_total > 0)
|
||||
.map((h) => ({
|
||||
symbol: h.symbol,
|
||||
cost: h.cost_basis_total,
|
||||
value: h.current_value ?? h.cost_basis_total,
|
||||
gain: (h.current_value ?? h.cost_basis_total) - h.cost_basis_total,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Cost Basis vs Current Value</p>
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, data.length * 52)}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="symbol"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => CurrencyYAxis({ value: v, currency: portfolio.currency })}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
formatCurrency(value, portfolio.currency),
|
||||
name === "cost" ? "Cost basis" : "Current value",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
|
||||
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
|
||||
{data.map((d) => (
|
||||
<Cell
|
||||
key={d.symbol}
|
||||
fill={d.gain >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-muted-foreground opacity-40" />
|
||||
Cost basis
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--success))" }} />
|
||||
Current value (gain)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--destructive))" }} />
|
||||
Current value (loss)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bar: return % per holding ─────────────────────────────────────────────────
|
||||
|
||||
export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
|
||||
const data = portfolio.holdings
|
||||
.filter((h) => h.unrealised_gain_pct != null)
|
||||
.map((h) => ({
|
||||
symbol: h.symbol,
|
||||
pct: Number(h.unrealised_gain_pct),
|
||||
gain: h.unrealised_gain ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const absMax = Math.max(...data.map((d) => Math.abs(d.pct)), 5);
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Return % by Holding</p>
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 44)}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 48, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[-absMax, absMax]}
|
||||
tickFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="symbol"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
|
||||
<LabelList
|
||||
dataKey="pct"
|
||||
position="right"
|
||||
formatter={(v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`}
|
||||
style={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
{data.map((d) => (
|
||||
<Cell
|
||||
key={d.symbol}
|
||||
fill={d.pct >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,12 +7,9 @@ import { formatCurrency } from "@/utils/currency";
|
|||
import { cn } from "@/utils/cn";
|
||||
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
|
||||
import AddHoldingModal from "./AddHoldingModal";
|
||||
import { AllocationDonut, AssetTypeDonut, CostVsValueChart, ReturnChart } from "./PortfolioCharts";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
|
||||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
||||
];
|
||||
|
||||
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
|
||||
holding: HoldingResponse;
|
||||
|
|
@ -156,14 +153,6 @@ export default function PortfolioPage() {
|
|||
},
|
||||
});
|
||||
|
||||
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">
|
||||
|
|
@ -201,34 +190,21 @@ export default function PortfolioPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocation bar */}
|
||||
{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>
|
||||
));
|
||||
})()}
|
||||
{/* Charts */}
|
||||
{portfolio && portfolio.holdings.length > 0 && (
|
||||
<>
|
||||
{/* Two donuts side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AllocationDonut portfolio={portfolio} />
|
||||
<AssetTypeDonut portfolio={portfolio} />
|
||||
</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>
|
||||
|
||||
{/* Cost vs value */}
|
||||
<CostVsValueChart portfolio={portfolio} />
|
||||
|
||||
{/* Return % per holding */}
|
||||
<ReturnChart portfolio={portfolio} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Holdings table */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue