Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| ChevronDown, | |
| ChevronUp, | |
| Plus, | |
| Search, | |
| TrendingUp, | |
| TrendingDown, | |
| Trash2, | |
| PieChart as PieIcon, | |
| BarChart3, | |
| Info | |
| } from 'lucide-react'; | |
| import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; | |
| import InvestmentCommittee from './InvestmentCommittee'; | |
| export default function HoldingsList({ | |
| allocation, | |
| prices, | |
| onAddClick, | |
| onAssetClick, | |
| onRemoveAsset | |
| }) { | |
| const [expandedId, setExpandedId] = useState(null); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const filteredAllocation = useMemo(() => { | |
| return allocation.filter(asset => | |
| asset.ticker.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| asset.name.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| }, [allocation, searchTerm]); | |
| const toggleExpand = (ticker) => { | |
| setExpandedId(expandedId === ticker ? null : ticker); | |
| }; | |
| return ( | |
| <div className="bg-white rounded-3xl shadow-xl border border-gray-100 overflow-hidden"> | |
| {/* Header section matching the user's requested layout */} | |
| <div className="p-6 border-b border-gray-50 flex flex-col md:flex-row justify-between items-center gap-4 bg-gs-light/30"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-gs-navy rounded-xl text-white shadow-lg shadow-gs-navy/20"> | |
| <BarChart3 size={20} /> | |
| </div> | |
| <h2 className="text-xl font-bold text-gs-navy tracking-tight">Portfolio Holdings</h2> | |
| </div> | |
| <div className="flex items-center gap-3 w-full md:w-auto"> | |
| <div className="relative flex-1 md:flex-none"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gs-slate/40" size={16} /> | |
| <input | |
| type="text" | |
| placeholder="Search holdings..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-gs-gold/30 focus:border-gs-gold transition-all w-full md:w-64 shadow-sm" | |
| /> | |
| </div> | |
| <button | |
| onClick={onAddClick} | |
| className="flex items-center gap-2 px-4 py-2 bg-gs-navy text-white rounded-xl hover:bg-gs-gold hover:text-gs-navy transition-all shadow-md font-bold text-sm whitespace-nowrap group" | |
| > | |
| <Plus size={16} className="group-hover:rotate-90 transition-transform duration-300" /> | |
| Add Holding | |
| </button> | |
| </div> | |
| </div> | |
| {/* Table Header */} | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left border-collapse"> | |
| <thead> | |
| <tr className="text-[10px] font-bold text-gs-slate/50 uppercase tracking-[0.1em] border-b border-gray-50"> | |
| <th className="px-6 py-4 font-bold">Asset</th> | |
| <th className="px-6 py-4 font-bold text-right">Price</th> | |
| <th className="px-6 py-4 font-bold text-right hidden sm:table-cell">Shares</th> | |
| <th className="px-6 py-4 font-bold text-right">Value</th> | |
| <th className="px-6 py-4 font-bold text-right hidden lg:table-cell">Avg Cost</th> | |
| <th className="px-6 py-4 font-bold text-right hidden md:table-cell">Gain/Loss</th> | |
| <th className="px-6 py-4 font-bold text-right">Return</th> | |
| <th className="px-6 py-4 font-bold text-right">Alloc</th> | |
| <th className="px-4 py-4 w-10"></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredAllocation.length > 0 ? ( | |
| filteredAllocation.map((asset) => ( | |
| <HoldingRow | |
| key={asset.ticker} | |
| asset={asset} | |
| priceData={prices[asset.ticker]} | |
| isExpanded={expandedId === asset.ticker} | |
| onToggle={() => toggleExpand(asset.ticker)} | |
| onRemove={() => onRemoveAsset(asset.ticker)} | |
| /> | |
| )) | |
| ) : ( | |
| <tr> | |
| <td colSpan="9" className="px-6 py-20 text-center"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <div className="p-4 bg-gs-light rounded-full text-gs-slate/20"> | |
| <Search size={40} /> | |
| </div> | |
| <p className="text-gs-slate font-light text-lg">No holdings found matching your search.</p> | |
| </div> | |
| </td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function HoldingRow({ asset, priceData, isExpanded, onToggle, onRemove }) { | |
| const currentPrice = priceData?.price || 0; | |
| const marketValue = asset.shares * currentPrice; | |
| const totalCost = asset.shares * (asset.avgCost || 0); | |
| const gainLoss = marketValue - totalCost; | |
| const returnPct = (totalCost > 0 && !isNaN(gainLoss)) ? (gainLoss / totalCost) * 100 : 0; | |
| const isPositive = gainLoss >= 0; | |
| return ( | |
| <> | |
| <tr | |
| onClick={onToggle} | |
| className={`group cursor-pointer border-b border-gray-50 transition-all ${isExpanded ? 'bg-gs-gold/5' : 'hover:bg-gs-light/50'}`} | |
| > | |
| {/* Asset Column */} | |
| <td className="px-6 py-4"> | |
| <div className="flex items-center gap-4"> | |
| <div | |
| className="w-10 h-10 rounded-xl flex items-center justify-center text-white font-bold text-xs shadow-inner" | |
| style={{ backgroundColor: asset.color }} | |
| > | |
| {asset.ticker?.substring(0, 2) || '??'} | |
| </div> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <span className="font-bold text-gs-navy uppercase tracking-wide">{asset.ticker}</span> | |
| <span className="text-[10px] px-1.5 py-0.5 bg-gs-light text-gs-slate font-bold rounded uppercase"> | |
| {asset.type === 'mf' ? 'ETF' : 'STOCK'} | |
| </span> | |
| </div> | |
| <p className="text-xs text-gs-slate/60 font-medium truncate max-w-[120px]">{asset.name}</p> | |
| {priceData?.percent !== undefined && !isNaN(priceData.percent) && ( | |
| <div className={`flex items-center gap-1 text-[10px] font-bold mt-0.5 ${priceData.percent >= 0 ? 'text-green-600' : 'text-red-500'}`}> | |
| {priceData.percent >= 0 ? '▲' : '▼'} {Math.abs(priceData.percent).toFixed(2)}% today | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </td> | |
| {/* Price Column */} | |
| <td className="px-6 py-4 text-right"> | |
| <p className="font-bold text-gs-navy">${currentPrice.toFixed(2)}</p> | |
| </td> | |
| {/* Shares Column */} | |
| <td className="px-6 py-4 text-right hidden sm:table-cell"> | |
| <p className="text-gs-slate font-medium">{asset.shares}</p> | |
| </td> | |
| {/* Value Column */} | |
| <td className="px-6 py-4 text-right"> | |
| <p className="font-bold text-gs-navy">${marketValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p> | |
| </td> | |
| {/* Avg Cost Column */} | |
| <td className="px-6 py-4 text-right hidden lg:table-cell"> | |
| <p className="text-gs-slate/60 font-medium">${(asset.avgCost || 0).toFixed(2)}</p> | |
| </td> | |
| {/* Gain/Loss Column */} | |
| <td className="px-6 py-4 text-right hidden md:table-cell"> | |
| <p className={`font-bold ${isPositive ? 'text-green-600' : 'text-red-500'}`}> | |
| {isPositive ? '+' : ''}${Math.abs(gainLoss).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |
| </p> | |
| </td> | |
| {/* Return Column */} | |
| <td className="px-6 py-4 text-right"> | |
| <div className={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-bold ${isPositive ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-500'}`}> | |
| {isPositive ? '+' : ''}{returnPct.toFixed(2)}% | |
| </div> | |
| </td> | |
| {/* Alloc Column */} | |
| <td className="px-6 py-4 text-right"> | |
| <div className="flex flex-col items-end gap-1.5"> | |
| <span className="font-bold text-gs-navy text-xs">{asset.value.toFixed(1)}%</span> | |
| <div className="w-16 h-1.5 bg-gs-light rounded-full overflow-hidden shadow-inner"> | |
| <div | |
| className="h-full rounded-full transition-all duration-1000" | |
| style={{ width: `${asset.value}%`, backgroundColor: asset.color }} | |
| ></div> | |
| </div> | |
| </div> | |
| </td> | |
| {/* Actions/Expand Column */} | |
| <td className="px-4 py-4" onClick={(e) => e.stopPropagation()}> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => onRemove()} | |
| className="p-1.5 text-gs-slate/20 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100" | |
| title="Remove Holding" | |
| > | |
| <Trash2 size={14} /> | |
| </button> | |
| <div className={`text-gs-slate/30 group-hover:text-gs-gold transition-colors ${isExpanded ? 'rotate-180' : ''}`}> | |
| <ChevronDown size={18} /> | |
| </div> | |
| </div> | |
| </td> | |
| </tr> | |
| {/* Expanded Section */} | |
| <AnimatePresence> | |
| {isExpanded && ( | |
| <motion.tr | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: 'auto' }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="bg-gs-light/30 border-b border-gray-50 overflow-hidden" | |
| > | |
| <td colSpan="9" className="p-0"> | |
| <div className="p-6 grid grid-cols-1 xl:grid-cols-3 gap-8"> | |
| {/* Visual Analysis */} | |
| <div className="xl:col-span-2 space-y-4"> | |
| <div className="flex justify-between items-end"> | |
| <div> | |
| <h4 className="text-sm font-bold text-gs-navy uppercase tracking-wider mb-1">Price Performance</h4> | |
| <p className="text-xs text-gs-slate/60">Real-time market tracking for {asset.ticker}</p> | |
| </div> | |
| <div className="flex gap-2"> | |
| <span className="text-[10px] font-bold px-2 py-1 bg-white border border-gray-100 rounded-lg shadow-sm">1 Month</span> | |
| </div> | |
| </div> | |
| <div className="h-48 bg-white p-4 rounded-2xl border border-gray-100 shadow-sm"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <AreaChart data={priceData?.history || []}> | |
| <defs> | |
| <linearGradient id={`color-${asset.ticker}`} x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="5%" stopColor={asset.color} stopOpacity={0.1}/> | |
| <stop offset="95%" stopColor={asset.color} stopOpacity={0}/> | |
| </linearGradient> | |
| </defs> | |
| <Tooltip | |
| contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)' }} | |
| /> | |
| <Area | |
| type="monotone" | |
| dataKey="price" | |
| stroke={asset.color} | |
| strokeWidth={2} | |
| fill={`url(#color-${asset.ticker})`} | |
| /> | |
| </AreaChart> | |
| </ResponsiveContainer> | |
| </div> | |
| <div className="grid grid-cols-3 gap-4"> | |
| <div className="bg-white p-3 rounded-xl border border-gray-100 shadow-sm"> | |
| <p className="text-[10px] font-bold text-gs-slate/40 uppercase mb-1">Volatility</p> | |
| <p className="text-sm font-bold text-gs-navy">Moderate</p> | |
| </div> | |
| <div className="bg-white p-3 rounded-xl border border-gray-100 shadow-sm"> | |
| <p className="text-[10px] font-bold text-gs-slate/40 uppercase mb-1">Buy Price</p> | |
| <p className="text-sm font-bold text-gs-navy">${(asset.avgCost || 0).toFixed(2)}</p> | |
| </div> | |
| <div className="bg-white p-3 rounded-xl border border-gray-100 shadow-sm"> | |
| <p className="text-[10px] font-bold text-gs-slate/40 uppercase mb-1">Hold Since</p> | |
| <p className="text-sm font-bold text-gs-navy">{new Date(asset.dateBought).toLocaleDateString()}</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* AI Analysis Sidebar */} | |
| <div className="space-y-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <div className="w-6 h-6 rounded-lg bg-gs-gold/20 flex items-center justify-center text-gs-gold"> | |
| <Info size={14} /> | |
| </div> | |
| <h4 className="text-sm font-bold text-gs-navy uppercase tracking-wider">AI Investment Thesis</h4> | |
| </div> | |
| <div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"> | |
| <InvestmentCommittee ticker={asset.ticker} inline={true} /> | |
| </div> | |
| </div> | |
| </div> | |
| </td> | |
| </motion.tr> | |
| )} | |
| </AnimatePresence> | |
| </> | |
| ); | |
| } | |