gs-port / src /pages /Dashboard.jsx
Scribbler310's picture
Upload folder using huggingface_hub
0c5252a verified
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>
);
}