import { useMemo, useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { WeightRing } from "@/components/charts/WeightRing"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import type { Holding } from "@/types/finance"; import { cn } from "@/lib/utils"; import { usePrivacy } from "@/lib/privacy"; import { formatMoney } from "@/lib/format"; import { Search } from "lucide-react"; interface Props { holdings: Holding[]; currency: string; investmentAccounts: { id: string; label: string }[]; } function fmt(v: number, c: string) { return formatMoney(v, c, { decimals: 0 }); } const TOP_N = 8; /** * Consolidated holdings, ranked by weight - the same table + ring pattern * already used on the per-account holdings page, just rolled up across every * connected source. Replaces the portfolio heatmap: a treemap needs every * tile to size against the others, so a handful of holdings with a missing * or zero price (common for a freshly-added manual position, or a brokerage * quote that hasn't synced yet) rendered as an invisible zero-area tile with * nothing on screen to say why. A table row always shows its value, even * when that value is $0. */ export function PortfolioAllocation({ holdings, currency, investmentAccounts }: Props) { // Local formatter reads privacy mode; subscribe so toggling it re-renders. usePrivacy(); const [query, setQuery] = useState(""); const accountLabel = useMemo( () => new Map(investmentAccounts.map((a) => [a.id, a.label])), [investmentAccounts] ); const { rows, top, restCount, restValue, restWeight } = useMemo(() => { const inCur = holdings.filter((h) => (h.currency ?? currency) === currency); const total = inCur.reduce((s, h) => s + h.shares * h.price, 0); const rows = inCur .map((h) => { const value = h.shares * h.price; return { ...h, value, returnVal: h.change * h.shares, weight: total > 0 ? (value / total) * 100 : 0, accountName: (h.investmentAccountId && accountLabel.get(h.investmentAccountId)) || "Manual", }; }) .sort((a, b) => b.value - a.value); const top = rows.slice(0, TOP_N); const rest = rows.slice(TOP_N); const restValue = rest.reduce((s, h) => s + h.value, 0); return { rows, top, restCount: rest.length, restValue, restWeight: total > 0 ? (restValue / total) * 100 : 0 }; }, [holdings, currency, accountLabel]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return null; return rows.filter((h) => h.symbol.toLowerCase().includes(q) || h.name.toLowerCase().includes(q)); }, [rows, query]); const shown = filtered ?? top; if (rows.length === 0) return null; return (
Showing {top.length} of {rows.length} holdings.
)}