import { format, isBefore } from "date-fns"; import { Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { usePortfolioSelector } from "../hooks/usePortfolio"; import { Investment } from "../types"; import { calculateInvestmentPerformance } from "../utils/calculations/performance"; import { downloadTableAsCSV, generatePortfolioPDF } from "../utils/export"; import { EditInvestmentModal } from "./Modals/EditInvestmentModal"; import { EditSavingsPlanModal } from "./Modals/EditSavingsPlanModal"; import { FutureProjectionModal } from "./Modals/FutureProjectionModal"; import { Tooltip } from "./utils/ToolTip"; interface SavingsPlanPerformance { assetName: string; amount: number; totalInvested: number; currentValue: number; performancePercentage: number; performancePerAnnoPerformance: number; allocation?: number; } export default function PortfolioTable() { const { assets, removeInvestment, clearInvestments } = usePortfolioSelector((state) => ({ assets: state.assets, removeInvestment: state.removeInvestment, clearInvestments: state.clearInvestments, })); const [editingInvestment, setEditingInvestment] = useState<{ investment: Investment; assetId: string; } | null>(null); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isUpdatingSavingsPlan, setIsUpdatingSavingsPlan] = useState(false); const [editingSavingsPlan, setEditingSavingsPlan] = useState<{ assetId: string; groupId: string; amount: number; dayOfMonth: number; interval: number; dynamic?: { type: 'percentage' | 'fixed'; value: number; yearInterval: number; }; } | null>(null); const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]); const averagePerformance = useMemo(() => { return ((performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length) || 0).toFixed(2); }, [performance.investments]); const handleDelete = useCallback((investmentId: string, assetId: string) => { if (window.confirm("Are you sure you want to delete this investment?")) { try { removeInvestment(assetId, investmentId); toast.success('Investment deleted successfully'); } catch (error:any) { toast.error('Failed to delete investment' + String(error?.message || error)); } } }, [removeInvestment]); const handleClearAll = useCallback(() => { if (window.confirm("Are you sure you want to clear all investments?")) { try { clearInvestments(); toast.success('All investments cleared successfully'); } catch (error:any) { toast.error('Failed to clear investments' + String(error?.message || error)); } } }, [clearInvestments]); const performanceTooltip = useMemo(() => (

The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%

The average (acc.) performance of all positions is {averagePerformance}%

The average (p.a.) performance of every year is {performance.summary.performancePerAnnoPerformance.toFixed(2)}%

Best p.a.: {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})

Worst p.a.: {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})

Note: An average performance of positions doesn't always match your entire portfolio's average, especially with single investments or investments on different time ranges.

), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance, performance.summary.bestPerformancePerAnno, performance.summary.worstPerformancePerAnno]); const buyInTooltip = useMemo(() => (

"Buy-in" shows the asset's price when that position was bought.

"Avg" shows the average buy-in price across all positions for that asset.

), []); const currentAmountTooltip = useMemo(() => ( "The current value of your investment based on the latest market price." ), []); const ttworTooltip = useMemo(() => (

Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.

It helps to evaluate the impact of your investment timing strategy compared to a single early investment.

), []); const [showProjection, setShowProjection] = useState(false); const isSavingsPlanOverviewDisabled = useMemo(() => { return !assets.some(asset => asset.investments.some(inv => inv.type === 'periodic')); }, [assets]); const savingsPlansPerformance = useMemo(() => { if(isSavingsPlanOverviewDisabled) return []; const performance: SavingsPlanPerformance[] = []; const totalSavingsPlansAmount = assets .map(v => v.investments) .flat() .filter(inv => inv.type === 'periodic') .reduce((sum, inv) => sum + inv.amount, 0); // Second pass to calculate individual performances with allocation for (const asset of assets) { const savingsPlans = asset.investments.filter(inv => inv.type === 'periodic'); const amount = savingsPlans.reduce((sum, inv) => sum + inv.amount, 0); if (savingsPlans.length > 0) { const assetPerformance = calculateInvestmentPerformance([{ ...asset, investments: savingsPlans }]); performance.push({ assetName: asset.name, amount: savingsPlans[0].amount, ...assetPerformance.summary, allocation: amount / totalSavingsPlansAmount * 100 }); } } return performance; }, [assets, isSavingsPlanOverviewDisabled]); const savingsPlansSummary = useMemo(() => { if (savingsPlansPerformance.length === 0) return null; const totalCurrentValue = savingsPlansPerformance.reduce((sum, plan) => sum + plan.currentValue, 0); const totalInvested = savingsPlansPerformance.reduce((sum, plan) => sum + plan.totalInvested, 0); const weightedPerformance = savingsPlansPerformance.reduce((sum, plan) => { return sum + (plan.performancePercentage * (plan.currentValue / totalCurrentValue)); }, 0); const weightedPerformancePA = savingsPlansPerformance.reduce((sum, plan) => { return sum + (plan.performancePerAnnoPerformance * (plan.currentValue / totalCurrentValue)); }, 0); return { totalAmount: savingsPlansPerformance.reduce((sum, plan) => sum + plan.amount, 0), totalInvested, totalCurrentValue, weightedPerformance, weightedPerformancePA, }; }, [savingsPlansPerformance]); const handleGeneratePDF = async () => { setIsGeneratingPDF(true); try { await generatePortfolioPDF( assets, performance, savingsPlansPerformance, performance.summary.performancePerAnnoPerformance ); toast.success('PDF generated successfully'); } catch (error:any) { toast.error('Failed to generate PDF' + String(error?.message || error)); } finally { setIsGeneratingPDF(false); } }; const handleDeleteSavingsPlan = useCallback((assetId: string, groupId: string) => { if (window.confirm("Are you sure you want to delete this savings plan? All related investments will be removed.")) { try { setIsUpdatingSavingsPlan(true); setTimeout(() => { try { const asset = assets.find(a => a.id === assetId); if (!asset) throw new Error('Asset not found'); const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId); investments.forEach(inv => { removeInvestment(assetId, inv.id); }); toast.success('Savings plan deleted successfully'); } catch (error:any) { toast.error('Failed to delete savings plan: ' + String(error?.message || error)); } finally { setIsUpdatingSavingsPlan(false); } }, 10); } catch (error:any) { toast.error('Failed to delete savings plan: ' + String(error?.message || error)); } } }, [assets, removeInvestment]); return (

Portfolio's Positions Overview

{!isSavingsPlanOverviewDisabled && savingsPlansPerformance.length > 0 && (

Savings Plans Performance

{savingsPlansSummary && ( )} {savingsPlansPerformance.sort((a, b) => Number(b.allocation || 0) - Number(a.allocation || 0)).map((plan) => { const asset = assets.find(a => a.name === plan.assetName)!; const firstInvestment = asset.investments.find(inv => inv.type === 'periodic')!; const groupId = firstInvestment.periodicGroupId!; return ( ); })}
Asset Interval Amount Allocation Total Invested Current Value Performance (%) Performance (p.a.) Actions
Total €{savingsPlansSummary.totalAmount.toFixed(2)} 100% €{savingsPlansSummary.totalInvested.toFixed(2)} €{savingsPlansSummary.totalCurrentValue.toFixed(2)} {savingsPlansSummary.weightedPerformance.toFixed(2)}% {savingsPlansSummary.weightedPerformancePA.toFixed(2)}%
{plan.assetName} €{plan.amount.toFixed(2)} {plan.allocation?.toFixed(2)}% €{plan.totalInvested.toFixed(2)} €{plan.currentValue.toFixed(2)} {plan.performancePercentage.toFixed(2)}% {plan.performancePerAnnoPerformance.toFixed(2)}%
)}

Positions Overview

{performance.summary && ( <> )} {performance.investments.sort((a, b) => isBefore(a.date, b.date) ? -1 : 1).map((inv, index) => { const asset = assets.find(a => a.name === inv.assetName)!; const investment = asset.investments.find(i => i.id === inv.id)! || inv; const filtered = performance.investments.filter(v => v.assetName === inv.assetName); const avgBuyIn = filtered.reduce((acc, curr) => acc + curr.investedAtPrice, 0) / filtered.length; const isLast = index === performance.investments.length - 1; return ( ); })}
Asset Type Date Invested Amount Current Amount Buy-In (avg) Performance (%) Actions
Total Portfolio €{performance.summary.totalInvested.toFixed(2)} €{performance.summary.currentValue.toFixed(2)} {performance.summary.performancePercentage.toFixed(2)}%
  • (avg. acc. {averagePerformance}%)
  • (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)
  • (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})
  • (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})
TTWOR {new Date(performance.investments[0]?.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} €{performance.summary.totalInvested.toFixed(2)} €{performance.summary.ttworValue.toFixed(2)} {performance.summary.ttworPercentage.toFixed(2)}%
{inv.assetName} {investment?.type === 'periodic' ? ( SavingsPlan ) : ( OneTime )} {format(new Date(inv.date), 'dd.MM.yyyy')} €{inv.investedAmount.toFixed(2)} €{inv.currentValue.toFixed(2)} €{inv.investedAtPrice.toFixed(2)} (€{avgBuyIn.toFixed(2)}) {inv.performancePercentage.toFixed(2)}%
{editingInvestment && ( setEditingInvestment(null)} /> )} {showProjection && ( setShowProjection(false)} /> )} {editingSavingsPlan && ( setEditingSavingsPlan(null)} /> )}
); };