investment-portfolio-simulator/src/components/PortfolioTable.tsx

511 lines
31 KiB
TypeScript

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(() => (
<div className="space-y-2">
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p>
<p>The average (acc.) performance of all positions is {averagePerformance}%</p>
<p>The average (p.a.) performance of every year is {performance.summary.performancePerAnnoPerformance.toFixed(2)}%</p>
<p>Best p.a.: {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</p>
<p>Worst p.a.: {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% ({performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</p>
<p className="text-xs mt-2">
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.
</p>
</div>
), [performance.summary.performancePercentage, averagePerformance, performance.summary.performancePerAnnoPerformance, performance.summary.bestPerformancePerAnno, performance.summary.worstPerformancePerAnno]);
const buyInTooltip = useMemo(() => (
<div className="space-y-2">
<p>"Buy-in" shows the asset's price when that position was bought.</p>
<p>"Avg" shows the average buy-in price across all positions for that asset.</p>
</div>
), []);
const currentAmountTooltip = useMemo(() => (
"The current value of your investment based on the latest market price."
), []);
const ttworTooltip = useMemo(() => (
<div className="space-y-2">
<p>Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.</p>
<p className="text-xs mt-2">
It helps to evaluate the impact of your investment timing strategy compared to a single early investment.
</p>
</div>
), []);
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 (
<div className="space-y-4">
<div className="overflow-x-auto min-h-[500px] dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
<div className="flex flex-wrap justify-between items-center mb-4">
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
<div className="flex flex-wrap gap-2">
<button
onClick={handleClearAll}
disabled={performance.investments.length === 0}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear All Investments
</button>
<button
onClick={() => 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"
>
<LineChart size={16} />
Future Projection
</button>
<button
onClick={handleGeneratePDF}
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={performance.investments.length === 0 || isGeneratingPDF}
>
{isGeneratingPDF ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FileDown size={16} />
)}
{isGeneratingPDF ? 'Generating...' : 'Save Analysis'}
</button>
</div>
</div>
{!isSavingsPlanOverviewDisabled && savingsPlansPerformance.length > 0 && (
<div className="overflow-x-auto mb-4 dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Savings Plans Performance</h3>
<button
onClick={() => downloadTableAsCSV(savingsPlansPerformance, 'savings-plans-performance')}
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
title="Download CSV"
>
<Download size={16} />
</button>
</div>
<table className="min-w-full bg-white dark:bg-slate-800">
<thead>
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
<th className="px-4 py-2">Interval Amount</th>
<th className="px-4 py-2">Allocation</th>
<th className="px-4 py-2">Total Invested</th>
<th className="px-4 py-2">Current Value</th>
<th className="px-4 py-2">Performance (%)</th>
<th className="px-4 py-2">Performance (p.a.)</th>
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
</tr>
</thead>
<tbody>
{savingsPlansSummary && (
<tr className="font-bold bg-gray-50 dark:bg-slate-700 border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">Total</td>
<td className="px-4 py-2">{savingsPlansSummary.totalAmount.toFixed(2)}</td>
<td className="px-4 py-2">100%</td>
<td className="px-4 py-2">{savingsPlansSummary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{savingsPlansSummary.totalCurrentValue.toFixed(2)}</td>
<td className="px-4 py-2">{savingsPlansSummary.weightedPerformance.toFixed(2)}%</td>
<td className="px-4 py-2">{savingsPlansSummary.weightedPerformancePA.toFixed(2)}%</td>
<td className="px-4 py-2"></td>
</tr>
)}
{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 (
<tr key={plan.assetName} className="border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">{plan.assetName}</td>
<td className="px-4 py-2">{plan.amount.toFixed(2)}</td>
<td className="px-4 py-2">{plan.allocation?.toFixed(2)}%</td>
<td className="px-4 py-2">{plan.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{plan.currentValue.toFixed(2)}</td>
<td className="px-4 py-2">{plan.performancePercentage.toFixed(2)}%</td>
<td className="px-4 py-2">{plan.performancePerAnnoPerformance.toFixed(2)}%</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<button
onClick={() => 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 ? (
<Loader2 className="animate-spin" size={16} />
) : (<Pencil className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleDeleteSavingsPlan(asset.id, groupId)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
{isUpdatingSavingsPlan || editingSavingsPlan ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="overflow-x-auto dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Positions Overview</h3>
<button
onClick={() => 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"
>
<Download size={16} />
</button>
</div>
<div className="rounded-lg">
<table className="min-w-full bg-white dark:bg-slate-800">
<thead>
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
<th className="px-4 py-2">Type</th>
<th className="px-4 py-2">Date</th>
<th className="px-4 py-2">Invested Amount</th>
<th className="px-4 py-2">
<Tooltip content={currentAmountTooltip}>
Current Amount
</Tooltip>
</th>
<th className="px-4 py-2">
<Tooltip content={buyInTooltip}>
Buy-In (avg)
</Tooltip>
</th>
<th className="px-4 py-2">
<Tooltip content={performanceTooltip}>
Performance (%)
</Tooltip>
</th>
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
</tr>
</thead>
<tbody>
{performance.summary && (
<>
<tr className="font-bold bg-gray-50 dark:bg-slate-700 border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">Total Portfolio</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{performance.summary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.currentValue.toFixed(2)}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">
{performance.summary.performancePercentage.toFixed(2)}%
<ul>
<li className="text-xs text-gray-500 dark:text-gray-400">(avg. acc. {averagePerformance}%)</li>
<li className="text-xs text-gray-500 dark:text-gray-400">(avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)</li>
<li className="text-[10px] text-gray-500 dark:text-gray-400 italic">(best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</li>
<li className="text-[10px] text-gray-500 dark:text-gray-400 italic">(worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</li>
</ul>
</td>
<td className="px-4 py-2"></td>
</tr>
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 ">
<td className="px-4 py-2">TTWOR</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{new Date(performance.investments[0]?.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}</td>
<td className="px-4 py-2">{performance.summary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.ttworValue.toFixed(2)}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"><Tooltip content={ttworTooltip}>{performance.summary.ttworPercentage.toFixed(2)}%</Tooltip></td>
<td className="px-4 py-2"></td>
</tr>
</>
)}
{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 (
<tr key={inv.id} className={`border-t border-gray-200 dark:border-slate-600 ${isLast ? 'last:rounded-b-lg' : ''}`}>
<td className={`px-4 py-2 ${isLast ? 'first:rounded-bl-lg' : ''}`}>{inv.assetName}</td>
<td className="px-4 py-2">
{investment?.type === 'periodic' ? (
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
<RefreshCw className="w-4 h-4 mr-1" />
SavingsPlan
</span>
) : (
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
<ShoppingBag className="w-4 h-4 mr-1" />
OneTime
</span>
)}
</td>
<td className="px-4 py-2">{format(new Date(inv.date), 'dd.MM.yyyy')}</td>
<td className="px-4 py-2">{inv.investedAmount.toFixed(2)}</td>
<td className="px-4 py-2">{inv.currentValue.toFixed(2)}</td>
<td className="px-4 py-2">{inv.investedAtPrice.toFixed(2)} ({avgBuyIn.toFixed(2)})</td>
<td className="px-4 py-2">{inv.performancePercentage.toFixed(2)}%</td>
<td className={`px-4 py-2 ${isLast ? 'last:rounded-br-lg' : ''}`}>
<div className="flex gap-2">
<button
onClick={() => setEditingInvestment({ investment, assetId: asset.id })}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(inv.id, asset.id)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{editingInvestment && (
<EditInvestmentModal
investment={editingInvestment.investment}
assetId={editingInvestment.assetId}
onClose={() => setEditingInvestment(null)}
/>
)}
{showProjection && (
<FutureProjectionModal
performancePerAnno={performance.summary.performancePerAnnoPerformance}
bestPerformancePerAnno={performance.summary.bestPerformancePerAnno}
worstPerformancePerAnno={performance.summary.worstPerformancePerAnno}
onClose={() => setShowProjection(false)}
/>
)}
{editingSavingsPlan && (
<EditSavingsPlanModal
{...editingSavingsPlan}
onClose={() => setEditingSavingsPlan(null)}
/>
)}
</div>
);
};