Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useEffect } from 'react'; | |
| import { fetchRealData } from '../data/historicalData'; | |
| import { mockPortfolios } from '../data/mockData'; | |
| import { tickerMetrics } from '../data/tickerMetrics'; | |
| import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; | |
| import Indicators from '../components/Indicators'; | |
| import FeeTransparencyModule from '../components/FeeTransparencyModule'; | |
| import RebalancingEngine from '../components/RebalancingEngine'; | |
| import TransparencyModal from '../components/TransparencyModal'; | |
| import MacroTracker from '../components/MacroTracker'; | |
| import StockPopup from '../components/StockPopup'; | |
| import PortfolioHeatmap from '../components/PortfolioHeatmap'; | |
| import FinancialCalculators from '../components/FinancialCalculators'; | |
| import { LayoutGrid, Plus, PieChart as PieIcon, BarChart3, X, ChevronRight } from 'lucide-react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import StockSearch from '../components/StockSearch'; | |
| import HoldingsList from '../components/HoldingsList'; | |
| export default function Dashboard({ riskProfile }) { | |
| // Initialize with a cached portfolio if available, otherwise an empty one | |
| const [currentPortfolio, setCurrentPortfolio] = useState(() => { | |
| const saved = localStorage.getItem('gs_portfolio'); | |
| if (saved) { | |
| try { | |
| return JSON.parse(saved); | |
| } catch (e) { | |
| console.error("Error loading saved portfolio:", e); | |
| } | |
| } | |
| return mockPortfolios[riskProfile] || { allocation: [] }; | |
| }); | |
| // Save to cache whenever portfolio changes | |
| useEffect(() => { | |
| localStorage.setItem('gs_portfolio', JSON.stringify(currentPortfolio)); | |
| }, [currentPortfolio]); | |
| const [modalData, setModalData] = useState(null); | |
| // State for popups | |
| const [activePopupAsset, setActivePopupAsset] = useState(null); | |
| const [showHeatmap, setShowHeatmap] = useState(false); | |
| const [isSearchOpen, setIsSearchOpen] = useState(false); | |
| const [isHoldingsOpen, setIsHoldingsOpen] = useState(false); | |
| const [totals, setTotals] = useState({ value: 0, change: 0, percent: 0, rawValue: 0, loading: true }); | |
| const [prices, setPrices] = useState({}); | |
| // Dynamic Calculation of Health, Risk, and Weights based on real metrics | |
| const displayPortfolio = useMemo(() => { | |
| let totalBeta = 0; | |
| let totalExpense = 0; | |
| const numAssets = currentPortfolio.allocation.length; | |
| // Calculate current market total | |
| let marketTotal = 0; | |
| currentPortfolio.allocation.forEach(asset => { | |
| const priceData = prices[asset.ticker] || { price: 0 }; | |
| marketTotal += asset.shares * priceData.price; | |
| }); | |
| const updatedAllocation = currentPortfolio.allocation.map(asset => { | |
| const priceData = prices[asset.ticker] || { price: 0, percent: 0 }; | |
| const marketVal = asset.shares * priceData.price; | |
| const weight = marketTotal > 0 ? (marketVal / marketTotal) * 100 : 0; | |
| const totalCost = asset.shares * (asset.buyPrice || priceData.price); | |
| const gainLoss = marketVal - totalCost; | |
| const returnPct = (totalCost > 0 && !isNaN(gainLoss) && isFinite(gainLoss)) ? (gainLoss / totalCost) * 100 : 0; | |
| const metrics = tickerMetrics[asset.ticker] || { beta: 1, expenseRatio: 0.1 }; | |
| totalBeta += (metrics.beta || 1) * (weight / 100); | |
| totalExpense += (metrics.expenseRatio || 0.1) * (weight / 100); | |
| return { | |
| ...asset, | |
| value: Number(weight.toFixed(2)), | |
| dayChange: priceData.percent, | |
| dollarChange: priceData.change, | |
| returnPct | |
| }; | |
| }); | |
| if (numAssets === 0) { | |
| return { | |
| ...currentPortfolio, | |
| healthScore: 0, | |
| riskLevel: 'None', | |
| avgBeta: '0.00', | |
| stockSplit: 0, | |
| mfSplit: 0 | |
| }; | |
| } | |
| // Determine Risk Level | |
| let riskLevel = 'Medium'; | |
| if (totalBeta < 0.6) riskLevel = 'Low'; | |
| else if (totalBeta > 1.2) riskLevel = 'High'; | |
| // Determine Health Score (Base 100) | |
| let healthScore = 100; | |
| healthScore -= Math.min(totalExpense * 100 * 2, 20); | |
| if (numAssets >= 5) healthScore += 5; | |
| const profileMap = { 'Cautious': 'Low', 'Balanced': 'Medium', 'Bold': 'High' }; | |
| if (riskLevel !== profileMap[riskProfile]) healthScore -= 15; | |
| healthScore = Math.min(Math.max(Math.round(healthScore), 0), 100); | |
| // Calculate Asset Type Split | |
| let stockWeight = 0; | |
| let mfWeight = 0; | |
| updatedAllocation.forEach(asset => { | |
| if (asset.type === 'mf') mfWeight += asset.value; | |
| else stockWeight += asset.value; | |
| }); | |
| return { | |
| ...currentPortfolio, | |
| allocation: updatedAllocation, | |
| healthScore, | |
| riskLevel, | |
| avgBeta: totalBeta.toFixed(2), | |
| stockSplit: stockWeight, | |
| mfSplit: mfWeight | |
| }; | |
| }, [currentPortfolio, riskProfile, prices]); | |
| const handleRebalance = (scenario) => { | |
| setModalData(scenario); | |
| }; | |
| const closeModal = () => setModalData(null); | |
| const closeStockPopup = () => setActivePopupAsset(null); | |
| const handleAddStock = (newAsset) => { | |
| setCurrentPortfolio(prev => { | |
| // If portfolio is empty, the first stock gets 100% allocation | |
| if (prev.allocation.length === 0) { | |
| return { | |
| ...prev, | |
| allocation: [{ ...newAsset, value: 100 }] | |
| }; | |
| } | |
| const scale = (100 - newAsset.value) / 100; | |
| const updatedExisting = prev.allocation.map(a => ({ | |
| ...a, | |
| value: Number((a.value * scale).toFixed(2)) | |
| })); | |
| // Ensure sum is exactly 100 | |
| const currentSum = updatedExisting.reduce((acc, a) => acc + a.value, 0) + newAsset.value; | |
| if (currentSum !== 100 && updatedExisting.length > 0) { | |
| updatedExisting[0].value += Number((100 - currentSum).toFixed(2)); | |
| } | |
| return { | |
| ...prev, | |
| allocation: [...updatedExisting, newAsset] | |
| }; | |
| }); | |
| }; | |
| const handleRemoveAsset = (ticker) => { | |
| setCurrentPortfolio(prev => { | |
| const updatedAllocation = prev.allocation.filter(a => a.ticker !== ticker); | |
| // Redistribute value to maintain 100% | |
| if (updatedAllocation.length > 0) { | |
| const currentSum = updatedAllocation.reduce((acc, a) => acc + a.value, 0); | |
| const scale = 100 / currentSum; | |
| updatedAllocation.forEach(a => { | |
| a.value = Number((a.value * scale).toFixed(2)); | |
| }); | |
| // Final adjust for rounding | |
| const finalSum = updatedAllocation.reduce((acc, a) => acc + a.value, 0); | |
| if (finalSum !== 100) { | |
| updatedAllocation[0].value += Number((100 - finalSum).toFixed(2)); | |
| } | |
| } | |
| return { | |
| ...prev, | |
| allocation: updatedAllocation | |
| }; | |
| }); | |
| }; | |
| useEffect(() => { | |
| async function calculateTotals() { | |
| if (currentPortfolio.allocation.length === 0) { | |
| setTotals({ value: '$0.00', change: '$0.00', percent: '0.00', isPositive: true, loading: false }); | |
| return; | |
| } | |
| setTotals(prev => ({ ...prev, loading: true })); | |
| const pricePromises = currentPortfolio.allocation.map(asset => fetchRealData(asset.ticker)); | |
| const results = await Promise.all(pricePromises); | |
| let newTotalValue = 0; | |
| let newTotalChange = 0; | |
| const priceMap = {}; | |
| results.forEach((data, index) => { | |
| const ticker = currentPortfolio.allocation[index].ticker; | |
| priceMap[ticker] = { | |
| price: data.currentPrice, | |
| change: data.change, | |
| percent: (data.change / (data.currentPrice - data.change)) * 100, | |
| history: data.history | |
| }; | |
| newTotalValue += currentPortfolio.allocation[index].shares * data.currentPrice; | |
| newTotalChange += (currentPortfolio.allocation[index].shares * data.change); | |
| }); | |
| setPrices(priceMap); | |
| const percent = newTotalValue > 0 ? (newTotalChange / (newTotalValue - newTotalChange)) * 100 : 0; | |
| // Update totals | |
| setTotals({ | |
| value: newTotalValue.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), | |
| change: newTotalChange.toLocaleString('en-US', { style: 'currency', currency: 'USD' }), | |
| percent: percent.toFixed(2), | |
| rawValue: newTotalValue, | |
| isPositive: newTotalChange >= 0, | |
| loading: false | |
| }); | |
| } | |
| calculateTotals(); | |
| }, [JSON.stringify(currentPortfolio.allocation.map(a => `${a.ticker}-${a.shares}`))]); | |
| return ( | |
| <div className="min-h-screen bg-gs-light p-6 md:p-12 relative"> | |
| <div className="max-w-7xl mx-auto"> | |
| <header className="mb-10 flex flex-col md:flex-row justify-between items-start md:items-end gap-6"> | |
| <div> | |
| <h1 className="text-3xl md:text-4xl font-light text-gs-navy mb-2"> | |
| Your <span className="font-semibold">{riskProfile}</span> Portfolio | |
| </h1> | |
| <p className="text-gs-slate text-lg font-light"> | |
| Built for your goals. Transparently managed. | |
| </p> | |
| </div> | |
| <div className="bg-white px-8 py-4 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-8 min-w-[320px]"> | |
| <div> | |
| <p className="text-[10px] font-bold text-gs-slate uppercase tracking-widest mb-1">Total Value</p> | |
| {totals.loading ? ( | |
| <div className="h-8 w-24 bg-gray-100 animate-pulse rounded"></div> | |
| ) : ( | |
| <p className="text-2xl font-bold text-gs-navy">{totals.value}</p> | |
| )} | |
| </div> | |
| <div className="h-10 w-px bg-gray-100"></div> | |
| <div> | |
| <p className="text-[10px] font-bold text-gs-slate uppercase tracking-widest mb-1">Day Change</p> | |
| {totals.loading ? ( | |
| <div className="h-8 w-24 bg-gray-100 animate-pulse rounded"></div> | |
| ) : ( | |
| <p className={`text-lg font-bold ${totals.isPositive ? 'text-green-600' : 'text-red-500'}`}> | |
| {totals.isPositive ? '+' : ''}{totals.change} | |
| <span className="text-xs ml-1 font-medium">({totals.isPositive ? '+' : ''}{totals.percent}%)</span> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </header> | |
| {/* Portfolio Overview & Holdings Section */} | |
| <div className="space-y-8 mb-10"> | |
| {/* Top Landscape "Tablet": Allocation & Summary */} | |
| <div className="bg-white rounded-[2.5rem] p-10 shadow-xl border border-gray-100 flex flex-col xl:flex-row items-center gap-12 relative overflow-hidden"> | |
| <div className="absolute top-0 right-0 w-64 h-64 bg-gs-gold/5 rounded-full -mr-32 -mt-32 blur-3xl"></div> | |
| {/* Left Column: Chart & Composition */} | |
| <div className="flex flex-col items-center xl:items-start flex-1 min-w-[320px]"> | |
| <h3 className="text-2xl font-bold text-gs-navy mb-6 flex items-center gap-3"> | |
| <PieIcon className="text-gs-gold" size={24} /> Allocation | |
| </h3> | |
| <div className="w-full h-64 mb-8"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <PieChart> | |
| <Pie | |
| data={displayPortfolio.allocation} | |
| cx="50%" | |
| cy="50%" | |
| innerRadius={85} | |
| outerRadius={115} | |
| paddingAngle={4} | |
| dataKey="value" | |
| nameKey="name" | |
| stroke="none" | |
| > | |
| {displayPortfolio.allocation.map((entry, index) => ( | |
| <Cell key={`cell-${index}`} fill={entry.color} /> | |
| ))} | |
| </Pie> | |
| <Tooltip | |
| contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' }} | |
| formatter={(value) => [`${value}%`, 'Weight']} | |
| /> | |
| </PieChart> | |
| </ResponsiveContainer> | |
| </div> | |
| {/* Portfolio Composition moved here under the chart */} | |
| <div className="w-full max-w-[300px] mt-2"> | |
| <div className="flex justify-between text-[10px] font-bold text-gs-navy uppercase tracking-widest mb-2.5"> | |
| <span>Equities ({displayPortfolio.stockSplit.toFixed(0)}%)</span> | |
| <span>Fixed Income ({displayPortfolio.mfSplit.toFixed(0)}%)</span> | |
| </div> | |
| <div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden flex shadow-inner"> | |
| <div className="h-full bg-gs-navy" style={{ width: `${displayPortfolio.stockSplit}%` }}></div> | |
| <div className="h-full bg-gs-gold" style={{ width: `${displayPortfolio.mfSplit}%` }}></div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right: Strategy, Transparency & Side-by-Side Actions */} | |
| <div className="flex-[1.5] w-full flex flex-col justify-between space-y-6"> | |
| <div className="space-y-4"> | |
| <div className="bg-gs-navy rounded-[2rem] p-6 text-white relative overflow-hidden shadow-lg border border-white/5"> | |
| <div className="absolute -right-6 -top-6 w-24 h-24 bg-gs-gold/10 rounded-full blur-2xl"></div> | |
| <h4 className="text-[9px] font-bold text-gs-gold uppercase tracking-[0.2em] mb-2.5">Strategy Target</h4> | |
| <p className="text-sm font-light leading-relaxed"> | |
| Portfolio optimized for a <span className="font-bold text-gs-gold">{riskProfile}</span> objective. | |
| </p> | |
| </div> | |
| <FeeTransparencyModule portfolio={displayPortfolio} /> | |
| </div> | |
| {/* Side-by-Side Action Buttons at the bottom */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <button | |
| onClick={() => setIsHoldingsOpen(true)} | |
| className="flex items-center justify-center gap-3 py-5 bg-gs-navy text-white rounded-2xl hover:bg-gs-gold hover:text-gs-navy transition-all group shadow-xl shadow-gs-navy/20 active:scale-95 border border-white/5" | |
| > | |
| <BarChart3 size={18} className="group-hover:scale-110 transition-transform" /> | |
| <span className="font-bold text-xs tracking-tight uppercase">Holdings</span> | |
| </button> | |
| <button | |
| onClick={() => setShowHeatmap(true)} | |
| className="flex items-center justify-center gap-3 py-5 bg-gs-light text-gs-navy rounded-2xl hover:bg-gs-navy hover:text-white transition-all group active:scale-95 border border-gs-navy/5 shadow-sm" | |
| > | |
| <LayoutGrid size={18} className="group-hover:scale-110 transition-transform" /> | |
| <span className="font-bold text-xs tracking-tight uppercase">Heatmap</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
| <Indicators portfolio={displayPortfolio} isFlattened={true} /> | |
| </div> | |
| </div> | |
| {/* Investment & Retirement Planning Section */} | |
| <FinancialCalculators /> | |
| <div className="grid grid-cols-1 xl:grid-cols-3 gap-8 mt-8"> | |
| {/* Rebalancing Engine - Spanning more width since Indicators are above */} | |
| <div className="xl:col-span-3"> | |
| <RebalancingEngine onScenarioSelect={handleRebalance} /> | |
| </div> | |
| </div> | |
| {/* Bottom Section: MacroTracker */} | |
| <MacroTracker /> | |
| </div> | |
| <TransparencyModal | |
| isOpen={!!modalData} | |
| onClose={closeModal} | |
| data={modalData} | |
| currentPortfolio={displayPortfolio} | |
| totalValue={totals.rawValue} | |
| prices={prices} | |
| /> | |
| <StockPopup | |
| ticker={activePopupAsset?.ticker} | |
| assetName={activePopupAsset?.name} | |
| onClose={closeStockPopup} | |
| allHoldings={displayPortfolio.allocation} | |
| prices={prices} | |
| /> | |
| <AnimatePresence> | |
| {isHoldingsOpen && ( | |
| <div className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-10 bg-gs-navy/40 backdrop-blur-md"> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.9, y: 20 }} | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.9, y: 20 }} | |
| className="w-full max-w-7xl max-h-full overflow-hidden flex flex-col relative" | |
| > | |
| <button | |
| onClick={() => setIsHoldingsOpen(false)} | |
| className="absolute top-4 right-6 z-10 p-2 bg-white/10 hover:bg-white/20 text-white rounded-full transition-all backdrop-blur-md border border-white/10" | |
| > | |
| <X size={20} /> | |
| </button> | |
| <div className="overflow-y-auto rounded-[2rem] shadow-2xl"> | |
| <HoldingsList | |
| allocation={displayPortfolio.allocation} | |
| prices={prices} | |
| onAddClick={() => setIsSearchOpen(true)} | |
| onRemoveAsset={handleRemoveAsset} | |
| /> | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| <AnimatePresence> | |
| {showHeatmap && ( | |
| <PortfolioHeatmap | |
| onClose={() => setShowHeatmap(false)} | |
| allocation={displayPortfolio.allocation} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| <AnimatePresence> | |
| {isSearchOpen && ( | |
| <StockSearch | |
| isOpen={isSearchOpen} | |
| onClose={() => setIsSearchOpen(false)} | |
| onAddStock={handleAddStock} | |
| currentPortfolio={displayPortfolio} | |
| totalValue={totals.value} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |