mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-07 11:50:36 +02:00
v1.1.0 - ability to edit savings plan, correct %performance calculation
This commit is contained in:
parent
d8ad384205
commit
a5d014ec4d
12 changed files with 452 additions and 57 deletions
26
package-lock.json
generated
26
package-lock.json
generated
|
@ -14,6 +14,7 @@
|
|||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
|
@ -2861,6 +2862,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
|
||||
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
|
@ -3735,6 +3745,22 @@
|
|||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
|
||||
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"goober": "^2.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "investment-portfolio-tracker",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@ -16,6 +16,7 @@
|
|||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { lazy, Suspense, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { AppShell } from "./components/Landing/AppShell";
|
||||
import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
|
||||
|
@ -19,6 +20,7 @@ export default function App() {
|
|||
/>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<Toaster position="bottom-right" />
|
||||
</PortfolioProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||
|
@ -81,9 +82,16 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log("submitting")
|
||||
console.time('generatePeriodicInvestments');
|
||||
console.timeLog('generatePeriodicInvestments', "1");
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(async () => {
|
||||
console.log("timeout")
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
|
@ -94,6 +102,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
date
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
toast.success('Investment added successfully');
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: date,
|
||||
|
@ -108,18 +117,23 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea
|
|||
},
|
||||
} : undefined),
|
||||
};
|
||||
console.timeLog('generatePeriodicInvestments', "2");
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
assetId
|
||||
);
|
||||
console.timeLog('generatePeriodicInvestments', "3");
|
||||
addInvestment(assetId, investments);
|
||||
|
||||
for (const investment of investments) {
|
||||
addInvestment(assetId, investment);
|
||||
}
|
||||
toast.success('Periodic investment plan created successfully');
|
||||
}
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to add investment. Please try again.' + String(error?.message || error));
|
||||
} finally {
|
||||
console.timeLog('generatePeriodicInvestments', "4");
|
||||
console.timeEnd('generatePeriodicInvestments');
|
||||
setIsSubmitting(false);
|
||||
setAmount('');
|
||||
clearSelectedAsset();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Loader2, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
|
@ -43,17 +44,23 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
|||
dateRange.endDate
|
||||
);
|
||||
|
||||
if (historicalData.length === 0) {
|
||||
toast.error(`No historical data available for ${asset.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const assetWithHistory = {
|
||||
...asset,
|
||||
// override name with the fetched long Name if available
|
||||
name: longName || asset.name,
|
||||
historicalData,
|
||||
};
|
||||
|
||||
addAsset(assetWithHistory);
|
||||
toast.success(`Successfully added ${assetWithHistory.name}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { Investment } from "../../types";
|
||||
|
@ -18,11 +19,16 @@ export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvest
|
|||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateInvestment(assetId, investment.id, {
|
||||
...investment,
|
||||
amount: parseFloat(amount),
|
||||
});
|
||||
onClose();
|
||||
try {
|
||||
updateInvestment(assetId, investment.id, {
|
||||
...investment,
|
||||
amount: parseFloat(amount),
|
||||
});
|
||||
toast.success('Investment updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update investment' + String(error?.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
236
src/components/Modals/EditSavingsPlanModal.tsx
Normal file
236
src/components/Modals/EditSavingsPlanModal.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
import { Loader2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { generatePeriodicInvestments } from "../../utils/calculations/assetValue";
|
||||
|
||||
interface EditSavingsPlanModalProps {
|
||||
assetId: string;
|
||||
groupId: string;
|
||||
amount: number;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditSavingsPlanModal = ({
|
||||
assetId,
|
||||
groupId,
|
||||
amount: initialAmount,
|
||||
dayOfMonth: initialDayOfMonth,
|
||||
interval: initialInterval,
|
||||
dynamic: initialDynamic,
|
||||
onClose
|
||||
}: EditSavingsPlanModalProps) => {
|
||||
const [amount, setAmount] = useState(initialAmount.toString());
|
||||
const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString());
|
||||
const [interval, setInterval] = useState(initialInterval.toString());
|
||||
const [isDynamic, setIsDynamic] = useState(!!initialDynamic);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || '');
|
||||
const [yearInterval, setYearInterval] = useState(initialDynamic?.yearInterval.toString() || '1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { dateRange, addInvestment, removeInvestment, assets } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
removeInvestment: state.removeInvestment,
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// First, remove all existing investments for this savings plan
|
||||
const asset = assets.find(a => a.id === assetId)!;
|
||||
const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId);
|
||||
const startDate = investments[0].date!; // Keep original start date
|
||||
|
||||
investments.forEach(inv => {
|
||||
removeInvestment(assetId, inv.id);
|
||||
});
|
||||
|
||||
// Generate and add new investments
|
||||
const periodicSettings = {
|
||||
startDate,
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const newInvestments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
assetId
|
||||
);
|
||||
|
||||
addInvestment(assetId, newInvestments);
|
||||
toast.success('Savings plan updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update savings plan: ' + String(error?.message || error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Edit Savings Plan</h2>
|
||||
<button onClick={onClose} className="p-2">
|
||||
<X className="w-6 h-6 dark:text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Investment Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Day of Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
max="31"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Interval (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDynamic}
|
||||
onChange={(e) => setIsDynamic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium">Dynamic Investment Growth</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isDynamic && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Growth Type
|
||||
</label>
|
||||
<select
|
||||
value={dynamicType}
|
||||
onChange={(e) => setDynamicType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Increase Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dynamicValue}
|
||||
onChange={(e) => setDynamicValue(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="0"
|
||||
step={dynamicType === 'percentage' ? '0.1' : '1'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Year Interval for Increase
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={yearInterval}
|
||||
onChange={(e) => setYearInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Plan'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,12 +3,14 @@ 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";
|
||||
|
||||
|
@ -23,8 +25,20 @@ export default function PortfolioTable() {
|
|||
investment: Investment;
|
||||
assetId: string;
|
||||
} | null>(null);
|
||||
const [showSavingsPlans, setShowSavingsPlans] = useState(true);
|
||||
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]);
|
||||
|
||||
|
@ -34,13 +48,23 @@ export default function PortfolioTable() {
|
|||
|
||||
const handleDelete = useCallback((investmentId: string, assetId: string) => {
|
||||
if (window.confirm("Are you sure you want to delete this investment?")) {
|
||||
removeInvestment(assetId, investmentId);
|
||||
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?")) {
|
||||
clearInvestments();
|
||||
try {
|
||||
clearInvestments();
|
||||
toast.success('All investments cleared successfully');
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to clear investments' + String(error?.message || error));
|
||||
}
|
||||
}
|
||||
}, [clearInvestments]);
|
||||
|
||||
|
@ -113,11 +137,39 @@ export default function PortfolioTable() {
|
|||
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">
|
||||
|
@ -140,15 +192,6 @@ export default function PortfolioTable() {
|
|||
Future Projection
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSavingsPlans(prev => !prev)}
|
||||
disabled={isSavingsPlanOverviewDisabled}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{showSavingsPlans ? 'Hide' : 'Show'} Savings Plans Performance
|
||||
</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"
|
||||
|
@ -164,7 +207,7 @@ export default function PortfolioTable() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!isSavingsPlanOverviewDisabled && showSavingsPlans && savingsPlansPerformance.length > 0 && (
|
||||
{!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>
|
||||
|
@ -184,20 +227,57 @@ export default function PortfolioTable() {
|
|||
<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 last:rounded-tr-lg">Performance (p.a.)</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>
|
||||
{savingsPlansPerformance.map((plan) => (
|
||||
<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}</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>
|
||||
</tr>
|
||||
))}
|
||||
{savingsPlansPerformance.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}</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: parseInt(firstInvestment.date!.split('-')[2]),
|
||||
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>
|
||||
|
@ -276,10 +356,10 @@ export default function PortfolioTable() {
|
|||
<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-xs text-gray-500 dark:text-gray-400"> (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</li>
|
||||
<li className="text-xs text-gray-500 dark:text-gray-400"> (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</li>
|
||||
<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>
|
||||
|
@ -366,6 +446,12 @@ export default function PortfolioTable() {
|
|||
onClose={() => setShowProjection(false)}
|
||||
/>
|
||||
)}
|
||||
{editingSavingsPlan && (
|
||||
<EditSavingsPlanModal
|
||||
{...editingSavingsPlan}
|
||||
onClose={() => setEditingSavingsPlan(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ type PortfolioAction =
|
|||
| { type: 'ADD_ASSET'; payload: Asset }
|
||||
| { type: 'REMOVE_ASSET'; payload: string }
|
||||
| { type: 'CLEAR_ASSETS' }
|
||||
| { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment } }
|
||||
| { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment | Investment[] } }
|
||||
| { type: 'REMOVE_INVESTMENT'; payload: { assetId: string; investmentId: string } }
|
||||
| { type: 'UPDATE_DATE_RANGE'; payload: DateRange }
|
||||
| { type: 'UPDATE_ASSET_HISTORICAL_DATA'; payload: { assetId: string; historicalData: HistoricalData[]; longName?: string } }
|
||||
|
@ -57,7 +57,7 @@ const portfolioReducer = (state: PortfolioState, action: PortfolioAction): Portf
|
|||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? { ...asset, investments: [...asset.investments, action.payload.investment] }
|
||||
? { ...asset, investments: [...asset.investments, ...(Array.isArray(action.payload.investment) ? action.payload.investment : [action.payload.investment])] }
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
@ -127,7 +127,7 @@ export interface PortfolioContextType extends PortfolioState {
|
|||
addAsset: (asset: Asset) => void;
|
||||
removeAsset: (assetId: string) => void;
|
||||
clearAssets: () => void;
|
||||
addInvestment: (assetId: string, investment: Investment) => void;
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) => void;
|
||||
removeInvestment: (assetId: string, investmentId: string) => void;
|
||||
updateDateRange: (dateRange: DateRange) => void;
|
||||
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[], longName?: string) => void;
|
||||
|
@ -148,7 +148,7 @@ export const PortfolioProvider = ({ children }: { children: React.ReactNode }) =
|
|||
addAsset: (asset: Asset) => dispatch({ type: 'ADD_ASSET', payload: asset }),
|
||||
removeAsset: (assetId: string) => dispatch({ type: 'REMOVE_ASSET', payload: assetId }),
|
||||
clearAssets: () => dispatch({ type: 'CLEAR_ASSETS' }),
|
||||
addInvestment: (assetId: string, investment: Investment) =>
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) =>
|
||||
dispatch({ type: 'ADD_INVESTMENT', payload: { assetId, investment } }),
|
||||
removeInvestment: (assetId: string, investmentId: string) =>
|
||||
dispatch({ type: 'REMOVE_INVESTMENT', payload: { assetId, investmentId } }),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// this is only needed when hosted staticly without a proxy server or smt
|
||||
// TODO change it to use the proxy server
|
||||
|
@ -49,6 +50,7 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
|
|||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching assets:', error);
|
||||
toast.error('Failed to search assets. Please try again later.');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
@ -81,6 +83,7 @@ export const getHistoricalData = async (symbol: string, startDate: string, endDa
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to fetch historical data for ${symbol}. Please try again later.`);
|
||||
return { historicalData: [], longName: '' };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -52,7 +52,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
const yearStart = new Date(year, 0, 1); // 1. Januar
|
||||
const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez.
|
||||
|
||||
const investmentsPerformances:number[] = [];
|
||||
const yearInvestments: { percent: number; weight: number }[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// Get prices for the start and end of the year
|
||||
|
@ -67,7 +67,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
|
||||
if (startPrice === 0 || endPrice === 0) {
|
||||
console.warn(`Skipping asset for year ${year} due to missing start or end price`);
|
||||
continue; // Überspringe, wenn keine Daten vorhanden
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all investments made before or during this year
|
||||
|
@ -88,19 +88,23 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
|
|||
(data) => isAfter(new Date(data.date), new Date(investment.date!))
|
||||
).find((v) => v.price !== 0)?.price || 0;
|
||||
|
||||
|
||||
if (buyInPrice > 0) {
|
||||
const shares = investment.amount / buyInPrice; // Berechne Anzahl der Shares
|
||||
const shares = investment.amount / buyInPrice;
|
||||
const endValue = shares * endPrice;
|
||||
const startValue = shares * startPrice;
|
||||
investmentsPerformances.push((endValue - startValue) / startValue * 100);
|
||||
yearInvestments.push({
|
||||
percent: ((endValue - startValue) / startValue) * 100,
|
||||
weight: startValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate performance for the year
|
||||
if (investmentsPerformances.length > 0) {
|
||||
const percentage = investmentsPerformances.reduce((acc, curr) => acc + curr, 0) / investmentsPerformances.length;
|
||||
// Calculate weighted average performance for the year
|
||||
if (yearInvestments.length > 0) {
|
||||
const totalWeight = yearInvestments.reduce((sum, inv) => sum + inv.weight, 0);
|
||||
const percentage = yearInvestments.reduce((sum, inv) =>
|
||||
sum + (inv.percent * (inv.weight / totalWeight)), 0);
|
||||
|
||||
if (!isNaN(percentage)) {
|
||||
annualPerformances.push({ year, percentage });
|
||||
|
|
|
@ -21,8 +21,12 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
percentageChange: 0,
|
||||
assets: {},
|
||||
};
|
||||
// this should contain the percentage gain of all investments till now
|
||||
const pPercents: number[] = [];
|
||||
|
||||
interface WeightedPercent {
|
||||
percent: number;
|
||||
weight: number;
|
||||
}
|
||||
const weightedPercents: WeightedPercent[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// calculate the invested kapital
|
||||
|
@ -49,14 +53,20 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
|
|||
dayData.assets[asset.id] = currentValueOfAsset;
|
||||
|
||||
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||
if (!Number.isNaN(percent)) pPercents.push(percent);
|
||||
if (!Number.isNaN(percent) && investedValue && investedValue > 0) {
|
||||
weightedPercents.push({
|
||||
percent,
|
||||
weight: investedValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate average percentage change if percentages array is not empty
|
||||
if (pPercents.length > 0) {
|
||||
dayData.percentageChange = pPercents.reduce((a, b) => a + b, 0) / pPercents.length;
|
||||
// Calculate weighted average percentage change
|
||||
if (weightedPercents.length > 0) {
|
||||
const totalWeight = weightedPercents.reduce((sum, wp) => sum + wp.weight, 0);
|
||||
dayData.percentageChange = weightedPercents.reduce((sum, wp) =>
|
||||
sum + (wp.percent * (wp.weight / totalWeight)), 0);
|
||||
} else {
|
||||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue