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
Clear All Investments
setShowProjection(true)}
disabled={performance.investments.length === 0}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Future Projection
{isGeneratingPDF ? (
) : (
)}
{isGeneratingPDF ? 'Generating...' : 'Save Analysis'}
{!isSavingsPlanOverviewDisabled && savingsPlansPerformance.length > 0 && (
Savings Plans Performance
downloadTableAsCSV(savingsPlansPerformance, 'savings-plans-performance')}
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
title="Download CSV"
>
Asset
Interval Amount
Allocation
Total Invested
Current Value
Performance (%)
Performance (p.a.)
Actions
{savingsPlansSummary && (
Total
€{savingsPlansSummary.totalAmount.toFixed(2)}
100%
€{savingsPlansSummary.totalInvested.toFixed(2)}
€{savingsPlansSummary.totalCurrentValue.toFixed(2)}
{savingsPlansSummary.weightedPerformance.toFixed(2)}%
{savingsPlansSummary.weightedPerformancePA.toFixed(2)}%
)}
{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 (
{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)}%
setEditingSavingsPlan({
assetId: asset.id,
groupId,
amount: firstInvestment.amount,
dayOfMonth: firstInvestment.date?.getDate() || 0,
interval: 30, // You might want to store this in the investment object
// Add dynamic settings if available
})}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
>
{isUpdatingSavingsPlan || editingSavingsPlan ? (
) : (
)}
handleDeleteSavingsPlan(asset.id, groupId)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
{isUpdatingSavingsPlan || editingSavingsPlan ? (
) : (
)}
);
})}
)}
Positions Overview
downloadTableAsCSV([
{
id: "",
assetName: "Total Portfolio",
date: "",
investedAmount: performance.summary.totalInvested.toFixed(2),
investedAtPrice: "",
currentValue: performance.summary.currentValue.toFixed(2),
performancePercentage: `${performance.summary.performancePercentage.toFixed(2)}% (avg. acc. ${averagePerformance}%) (avg. p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`,
periodicGroupId: "",
},
{
id: "",
assetName: "TTWOR",
date: "",
investedAmount: performance.summary.totalInvested.toFixed(2),
investedAtPrice: "",
currentValue: performance.summary.ttworValue.toFixed(2),
performancePercentage: `${performance.summary.ttworPercentage.toFixed(2)}%`,
periodicGroupId: "",
},
...performance.investments
], 'portfolio-positions')}
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
title="Download CSV"
>
Asset
Type
Date
Invested Amount
Current Amount
Buy-In (avg)
Performance (%)
Actions
{performance.summary && (
<>
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)}%
>
)}
{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 (
{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)}%
setEditingInvestment({ investment, assetId: asset.id })}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
>
handleDelete(inv.id, asset.id)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
);
})}
{editingInvestment && (
setEditingInvestment(null)}
/>
)}
{showProjection && (
setShowProjection(false)}
/>
)}
{editingSavingsPlan && (
setEditingSavingsPlan(null)}
/>
)}
);
};