[OQI-008] feat: Add Portfolio Manager frontend module
- Created portfolio.service.ts with API client functions - Created AllocationChart component (donut chart) - Created AllocationTable component (detailed positions) - Created RebalanceCard component (rebalancing recommendations) - Created GoalCard component (financial goal progress) - Created PortfolioDashboard page (main dashboard) - Created CreatePortfolio page (new portfolio form) - Created CreateGoal page (new goal form) - Updated App.tsx with portfolio routes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b7de2a3d58
commit
b8a7cbe691
10
src/App.tsx
10
src/App.tsx
@ -30,6 +30,11 @@ const Investment = lazy(() => import('./modules/investment/pages/Investment'));
|
||||
const Settings = lazy(() => import('./modules/settings/pages/Settings'));
|
||||
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
||||
|
||||
// Lazy load modules - Portfolio
|
||||
const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard'));
|
||||
const CreatePortfolio = lazy(() => import('./modules/portfolio/pages/CreatePortfolio'));
|
||||
const CreateGoal = lazy(() => import('./modules/portfolio/pages/CreateGoal'));
|
||||
|
||||
// Lazy load modules - Education
|
||||
const Courses = lazy(() => import('./modules/education/pages/Courses'));
|
||||
const CourseDetail = lazy(() => import('./modules/education/pages/CourseDetail'));
|
||||
@ -82,6 +87,11 @@ function App() {
|
||||
<Route path="/backtesting" element={<BacktestingDashboard />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
|
||||
{/* Portfolio Manager */}
|
||||
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
||||
<Route path="/portfolio/new" element={<CreatePortfolio />} />
|
||||
<Route path="/portfolio/goals/new" element={<CreateGoal />} />
|
||||
|
||||
{/* Education */}
|
||||
<Route path="/education/courses" element={<Courses />} />
|
||||
<Route path="/education/courses/:slug" element={<CourseDetail />} />
|
||||
|
||||
137
src/modules/portfolio/components/AllocationChart.tsx
Normal file
137
src/modules/portfolio/components/AllocationChart.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Allocation Chart Component
|
||||
* Displays portfolio allocations as a donut chart
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { PortfolioAllocation } from '../../../services/portfolio.service';
|
||||
|
||||
interface AllocationChartProps {
|
||||
allocations: PortfolioAllocation[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Asset colors mapping
|
||||
const ASSET_COLORS: Record<string, string> = {
|
||||
BTC: '#F7931A',
|
||||
ETH: '#627EEA',
|
||||
USDT: '#26A17B',
|
||||
SOL: '#9945FF',
|
||||
LINK: '#2A5ADA',
|
||||
AVAX: '#E84142',
|
||||
ADA: '#0033AD',
|
||||
DOT: '#E6007A',
|
||||
MATIC: '#8247E5',
|
||||
default: '#6B7280',
|
||||
};
|
||||
|
||||
function getAssetColor(asset: string): string {
|
||||
return ASSET_COLORS[asset] || ASSET_COLORS.default;
|
||||
}
|
||||
|
||||
export const AllocationChart: React.FC<AllocationChartProps> = ({
|
||||
allocations,
|
||||
size = 200,
|
||||
}) => {
|
||||
const radius = size / 2;
|
||||
const innerRadius = radius * 0.6;
|
||||
const centerX = radius;
|
||||
const centerY = radius;
|
||||
|
||||
// Calculate segments
|
||||
let currentAngle = -90; // Start from top
|
||||
const segments = allocations.map((alloc) => {
|
||||
const angle = (alloc.currentPercent / 100) * 360;
|
||||
const startAngle = currentAngle;
|
||||
const endAngle = currentAngle + angle;
|
||||
currentAngle = endAngle;
|
||||
|
||||
// Convert to radians
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
|
||||
// Calculate arc path
|
||||
const x1 = centerX + radius * Math.cos(startRad);
|
||||
const y1 = centerY + radius * Math.sin(startRad);
|
||||
const x2 = centerX + radius * Math.cos(endRad);
|
||||
const y2 = centerY + radius * Math.sin(endRad);
|
||||
|
||||
const x1Inner = centerX + innerRadius * Math.cos(startRad);
|
||||
const y1Inner = centerY + innerRadius * Math.sin(startRad);
|
||||
const x2Inner = centerX + innerRadius * Math.cos(endRad);
|
||||
const y2Inner = centerY + innerRadius * Math.sin(endRad);
|
||||
|
||||
const largeArc = angle > 180 ? 1 : 0;
|
||||
|
||||
const pathData = [
|
||||
`M ${x1Inner} ${y1Inner}`,
|
||||
`L ${x1} ${y1}`,
|
||||
`A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`,
|
||||
`L ${x2Inner} ${y2Inner}`,
|
||||
`A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
|
||||
return {
|
||||
...alloc,
|
||||
pathData,
|
||||
color: getAssetColor(alloc.asset),
|
||||
};
|
||||
});
|
||||
|
||||
const totalValue = allocations.reduce((sum, a) => sum + a.value, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{segments.map((segment, index) => (
|
||||
<path
|
||||
key={index}
|
||||
d={segment.pathData}
|
||||
fill={segment.color}
|
||||
className="transition-opacity hover:opacity-80 cursor-pointer"
|
||||
>
|
||||
<title>
|
||||
{segment.asset}: {segment.currentPercent.toFixed(1)}% ($
|
||||
{segment.value.toLocaleString()})
|
||||
</title>
|
||||
</path>
|
||||
))}
|
||||
{/* Center text */}
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-500 dark:fill-gray-400 text-xs"
|
||||
>
|
||||
Valor Total
|
||||
</text>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY + 15}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-900 dark:fill-white text-lg font-bold"
|
||||
>
|
||||
${totalValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-4">
|
||||
{allocations.map((alloc) => (
|
||||
<div key={alloc.asset} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getAssetColor(alloc.asset) }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{alloc.asset} ({alloc.currentPercent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllocationChart;
|
||||
156
src/modules/portfolio/components/AllocationTable.tsx
Normal file
156
src/modules/portfolio/components/AllocationTable.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Allocation Table Component
|
||||
* Displays portfolio allocations in a table format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
MinusIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { PortfolioAllocation } from '../../../services/portfolio.service';
|
||||
|
||||
interface AllocationTableProps {
|
||||
allocations: PortfolioAllocation[];
|
||||
showDeviation?: boolean;
|
||||
}
|
||||
|
||||
// Asset icons mapping
|
||||
const ASSET_ICONS: Record<string, string> = {
|
||||
BTC: 'https://cryptologos.cc/logos/bitcoin-btc-logo.svg',
|
||||
ETH: 'https://cryptologos.cc/logos/ethereum-eth-logo.svg',
|
||||
USDT: 'https://cryptologos.cc/logos/tether-usdt-logo.svg',
|
||||
SOL: 'https://cryptologos.cc/logos/solana-sol-logo.svg',
|
||||
LINK: 'https://cryptologos.cc/logos/chainlink-link-logo.svg',
|
||||
AVAX: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg',
|
||||
};
|
||||
|
||||
export const AllocationTable: React.FC<AllocationTableProps> = ({
|
||||
allocations,
|
||||
showDeviation = true,
|
||||
}) => {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Activo
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Cantidad
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Valor
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
% Actual
|
||||
</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
% Objetivo
|
||||
</th>
|
||||
{showDeviation && (
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Desviación
|
||||
</th>
|
||||
)}
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
P&L
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allocations.map((alloc) => (
|
||||
<tr
|
||||
key={alloc.id}
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{ASSET_ICONS[alloc.asset] ? (
|
||||
<img
|
||||
src={ASSET_ICONS[alloc.asset]}
|
||||
alt={alloc.asset}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs font-bold">
|
||||
{alloc.asset.slice(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{alloc.asset}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-right py-4 px-4 text-gray-900 dark:text-white">
|
||||
{alloc.quantity.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 8,
|
||||
})}
|
||||
</td>
|
||||
<td className="text-right py-4 px-4 text-gray-900 dark:text-white font-medium">
|
||||
${alloc.value.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="text-right py-4 px-4 text-gray-900 dark:text-white">
|
||||
{alloc.currentPercent.toFixed(1)}%
|
||||
</td>
|
||||
<td className="text-right py-4 px-4 text-gray-500 dark:text-gray-400">
|
||||
{alloc.targetPercent.toFixed(1)}%
|
||||
</td>
|
||||
{showDeviation && (
|
||||
<td className="text-right py-4 px-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{Math.abs(alloc.deviation) < 1 ? (
|
||||
<MinusIcon className="w-4 h-4 text-gray-400" />
|
||||
) : alloc.deviation > 0 ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={`${
|
||||
Math.abs(alloc.deviation) < 1
|
||||
? 'text-gray-400'
|
||||
: alloc.deviation > 0
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{alloc.deviation > 0 ? '+' : ''}
|
||||
{alloc.deviation.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="text-right py-4 px-4">
|
||||
<div className="flex flex-col items-end">
|
||||
<span
|
||||
className={`font-medium ${
|
||||
alloc.pnl >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{alloc.pnl >= 0 ? '+' : ''}${alloc.pnl.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
alloc.pnlPercent >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
({alloc.pnlPercent >= 0 ? '+' : ''}
|
||||
{alloc.pnlPercent.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllocationTable;
|
||||
168
src/modules/portfolio/components/GoalCard.tsx
Normal file
168
src/modules/portfolio/components/GoalCard.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Goal Card Component
|
||||
* Displays a financial goal with progress
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CalendarIcon,
|
||||
BanknotesIcon,
|
||||
TrashIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { PortfolioGoal } from '../../../services/portfolio.service';
|
||||
|
||||
interface GoalCardProps {
|
||||
goal: PortfolioGoal;
|
||||
onDelete?: (goalId: string) => void;
|
||||
onUpdateProgress?: (goalId: string) => void;
|
||||
}
|
||||
|
||||
export const GoalCard: React.FC<GoalCardProps> = ({
|
||||
goal,
|
||||
onDelete,
|
||||
onUpdateProgress,
|
||||
}) => {
|
||||
const progressPercent = Math.min(100, goal.progress);
|
||||
const daysRemaining = Math.ceil(
|
||||
(new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const monthsRemaining = Math.ceil(daysRemaining / 30);
|
||||
|
||||
const statusConfig = {
|
||||
on_track: {
|
||||
icon: CheckCircleIcon,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
label: 'En camino',
|
||||
},
|
||||
at_risk: {
|
||||
icon: ClockIcon,
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
label: 'En riesgo',
|
||||
},
|
||||
behind: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
label: 'Atrasado',
|
||||
},
|
||||
};
|
||||
|
||||
const status = statusConfig[goal.status];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${status.bgColor}`}>
|
||||
<StatusIcon className={`w-5 h-5 ${status.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">{goal.name}</h3>
|
||||
<span className={`text-sm ${status.color}`}>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(goal.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">Progreso</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{progressPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
goal.status === 'on_track'
|
||||
? 'bg-green-500'
|
||||
: goal.status === 'at_risk'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BanknotesIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Actual</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
${goal.currentAmount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Meta</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
${goal.targetAmount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Fecha objetivo</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(goal.targetDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Tiempo restante</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{monthsRemaining > 0 ? `${monthsRemaining} meses` : 'Vencido'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Aporte mensual</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
${goal.monthlyContribution.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{goal.projectedCompletion && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Proyección</span>
|
||||
<span className="text-green-500">
|
||||
{new Date(goal.projectedCompletion).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{onUpdateProgress && (
|
||||
<button
|
||||
onClick={() => onUpdateProgress(goal.id)}
|
||||
className="mt-4 w-full py-2 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
Actualizar Progreso
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoalCard;
|
||||
148
src/modules/portfolio/components/RebalanceCard.tsx
Normal file
148
src/modules/portfolio/components/RebalanceCard.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Rebalance Card Component
|
||||
* Displays rebalancing recommendations
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
MinusIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { RebalanceRecommendation } from '../../../services/portfolio.service';
|
||||
|
||||
interface RebalanceCardProps {
|
||||
recommendations: RebalanceRecommendation[];
|
||||
onExecute?: () => void;
|
||||
isExecuting?: boolean;
|
||||
}
|
||||
|
||||
export const RebalanceCard: React.FC<RebalanceCardProps> = ({
|
||||
recommendations,
|
||||
onExecute,
|
||||
isExecuting = false,
|
||||
}) => {
|
||||
const hasRecommendations = recommendations.some((r) => r.action !== 'hold');
|
||||
const highPriorityCount = recommendations.filter((r) => r.priority === 'high').length;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<span className="text-2xl">⚖️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Rebalanceo</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{hasRecommendations
|
||||
? `${highPriorityCount} acción(es) prioritaria(s)`
|
||||
: 'Portfolio balanceado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasRecommendations && onExecute && (
|
||||
<button
|
||||
onClick={onExecute}
|
||||
disabled={isExecuting}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Ejecutando...
|
||||
</>
|
||||
) : (
|
||||
'Ejecutar Rebalanceo'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasRecommendations ? (
|
||||
<div className="text-center py-8">
|
||||
<span className="text-4xl">✅</span>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Tu portfolio está bien balanceado
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-1">
|
||||
No se requieren ajustes en este momento
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recommendations
|
||||
.filter((r) => r.action !== 'hold')
|
||||
.sort((a, b) => {
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
})
|
||||
.map((rec, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between p-4 rounded-lg ${
|
||||
rec.priority === 'high'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
: rec.priority === 'medium'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{rec.action === 'buy' ? (
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<ArrowUpIcon className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
) : rec.action === 'sell' ? (
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full">
|
||||
<ArrowDownIcon className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<MinusIcon className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{rec.asset}
|
||||
</span>
|
||||
{rec.priority === 'high' && (
|
||||
<ExclamationTriangleIcon className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{rec.currentPercent.toFixed(1)}% → {rec.targetPercent.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
rec.action === 'buy' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{rec.action === 'buy' ? 'Comprar' : 'Vender'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
~${rec.amountUSD.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRecommendations && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 Se recomienda rebalancear cuando la desviación supera el 5% del objetivo
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RebalanceCard;
|
||||
307
src/modules/portfolio/pages/CreateGoal.tsx
Normal file
307
src/modules/portfolio/pages/CreateGoal.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Create Goal Page
|
||||
* Form to create a new financial goal
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeftIcon, CalendarIcon, BanknotesIcon } from '@heroicons/react/24/solid';
|
||||
import { createGoal } from '../../../services/portfolio.service';
|
||||
|
||||
// ============================================================================
|
||||
// Preset Goals
|
||||
// ============================================================================
|
||||
|
||||
const GOAL_PRESETS = [
|
||||
{ name: 'Fondo de Emergencia', icon: '🛡️', amount: 5000 },
|
||||
{ name: 'Vacaciones', icon: '✈️', amount: 3000 },
|
||||
{ name: 'Auto Nuevo', icon: '🚗', amount: 20000 },
|
||||
{ name: 'Casa Propia', icon: '🏠', amount: 50000 },
|
||||
{ name: 'Retiro', icon: '🌴', amount: 100000 },
|
||||
{ name: 'Educación', icon: '🎓', amount: 15000 },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function CreateGoal() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [targetAmount, setTargetAmount] = useState('');
|
||||
const [targetDate, setTargetDate] = useState('');
|
||||
const [monthlyContribution, setMonthlyContribution] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Calculate suggested monthly contribution
|
||||
const calculateSuggestion = () => {
|
||||
if (!targetAmount || !targetDate) return null;
|
||||
|
||||
const target = parseFloat(targetAmount);
|
||||
const months = Math.ceil(
|
||||
(new Date(targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24 * 30)
|
||||
);
|
||||
|
||||
if (months <= 0 || isNaN(target)) return null;
|
||||
|
||||
return (target / months).toFixed(2);
|
||||
};
|
||||
|
||||
const suggestedContribution = calculateSuggestion();
|
||||
|
||||
const handlePresetSelect = (preset: typeof GOAL_PRESETS[0]) => {
|
||||
setName(preset.name);
|
||||
setTargetAmount(preset.amount.toString());
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetAmount || parseFloat(targetAmount) <= 0) {
|
||||
setError('El monto objetivo debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetDate) {
|
||||
setError('La fecha objetivo es requerida');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(targetDate) <= new Date()) {
|
||||
setError('La fecha objetivo debe ser en el futuro');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await createGoal({
|
||||
name: name.trim(),
|
||||
targetAmount: parseFloat(targetAmount),
|
||||
targetDate,
|
||||
monthlyContribution: monthlyContribution
|
||||
? parseFloat(monthlyContribution)
|
||||
: parseFloat(suggestedContribution || '0'),
|
||||
});
|
||||
|
||||
navigate('/portfolio?tab=goals');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear la meta');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate minimum date (tomorrow)
|
||||
const minDate = new Date();
|
||||
minDate.setDate(minDate.getDate() + 1);
|
||||
const minDateStr = minDate.toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/portfolio?tab=goals')}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
Volver
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Nueva Meta Financiera
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Establece un objetivo y monitorea tu progreso
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preset Goals */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<label className="block mb-3 font-medium text-gray-900 dark:text-white">
|
||||
Metas Predefinidas
|
||||
</label>
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{GOAL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
type="button"
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-center"
|
||||
>
|
||||
<span className="text-2xl block mb-1">{preset.icon}</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{preset.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Goal Name */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
Nombre de la Meta
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ej: Fondo de emergencia"
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target Amount & Date */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
<BanknotesIcon className="w-5 h-5 inline mr-2 text-gray-400" />
|
||||
Monto Objetivo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={targetAmount}
|
||||
onChange={(e) => setTargetAmount(e.target.value)}
|
||||
placeholder="10,000"
|
||||
min="1"
|
||||
step="1"
|
||||
className="w-full pl-8 pr-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
<CalendarIcon className="w-5 h-5 inline mr-2 text-gray-400" />
|
||||
Fecha Objetivo
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={targetDate}
|
||||
onChange={(e) => setTargetDate(e.target.value)}
|
||||
min={minDateStr}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Contribution */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
Aporte Mensual
|
||||
</label>
|
||||
{suggestedContribution && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Sugerido: ${suggestedContribution}/mes para alcanzar tu meta
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={monthlyContribution}
|
||||
onChange={(e) => setMonthlyContribution(e.target.value)}
|
||||
placeholder={suggestedContribution || '500'}
|
||||
min="0"
|
||||
step="1"
|
||||
className="w-full pl-8 pr-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{suggestedContribution && !monthlyContribution && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonthlyContribution(suggestedContribution)}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Usar monto sugerido
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Preview */}
|
||||
{name && targetAmount && targetDate && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-xl p-6 border border-green-200 dark:border-green-800">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white mb-3">
|
||||
Resumen de tu Meta
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Meta</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Objetivo</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
${parseFloat(targetAmount).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Fecha</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{new Date(targetDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Aporte mensual
|
||||
</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
${monthlyContribution || suggestedContribution || '0'}/mes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/portfolio?tab=goals')}
|
||||
className="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
'Crear Meta'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
src/modules/portfolio/pages/CreatePortfolio.tsx
Normal file
240
src/modules/portfolio/pages/CreatePortfolio.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Create Portfolio Page
|
||||
* Form to create a new portfolio with risk profile selection
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
ScaleIcon,
|
||||
RocketLaunchIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { createPortfolio, type RiskProfile } from '../../../services/portfolio.service';
|
||||
|
||||
// ============================================================================
|
||||
// Risk Profile Options
|
||||
// ============================================================================
|
||||
|
||||
const RISK_PROFILES: {
|
||||
id: RiskProfile;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
features: string[];
|
||||
}[] = [
|
||||
{
|
||||
id: 'conservative',
|
||||
name: 'Conservador',
|
||||
description: 'Prioriza la preservación del capital con menor volatilidad',
|
||||
icon: ShieldCheckIcon,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
features: ['50% Stablecoins', '30% Bitcoin', '20% Ethereum', 'Bajo riesgo'],
|
||||
},
|
||||
{
|
||||
id: 'moderate',
|
||||
name: 'Moderado',
|
||||
description: 'Balance entre crecimiento y estabilidad',
|
||||
icon: ScaleIcon,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
features: ['20% Stablecoins', '40% Bitcoin', '25% Ethereum', '15% Altcoins'],
|
||||
},
|
||||
{
|
||||
id: 'aggressive',
|
||||
name: 'Agresivo',
|
||||
description: 'Maximiza el potencial de crecimiento con mayor volatilidad',
|
||||
icon: RocketLaunchIcon,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
features: ['10% Stablecoins', '30% Bitcoin', '25% Ethereum', '35% Altcoins'],
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function CreatePortfolio() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedProfile, setSelectedProfile] = useState<RiskProfile | null>(null);
|
||||
const [initialValue, setInitialValue] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProfile) {
|
||||
setError('Selecciona un perfil de riesgo');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await createPortfolio({
|
||||
name: name.trim(),
|
||||
riskProfile: selectedProfile,
|
||||
initialValue: initialValue ? parseFloat(initialValue) : undefined,
|
||||
});
|
||||
|
||||
navigate('/portfolio');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear el portfolio');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/portfolio')}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
Volver
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Crear Nuevo Portfolio
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configura tu portfolio según tu perfil de riesgo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Portfolio Name */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
Nombre del Portfolio
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ej: Mi Portfolio Principal"
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Risk Profile Selection */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<label className="block mb-4 font-medium text-gray-900 dark:text-white">
|
||||
Perfil de Riesgo
|
||||
</label>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{RISK_PROFILES.map((profile) => {
|
||||
const Icon = profile.icon;
|
||||
const isSelected = selectedProfile === profile.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProfile(profile.id)}
|
||||
className={`p-4 rounded-xl text-left transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${profile.bgColor} w-fit mb-3`}>
|
||||
<Icon className={`w-6 h-6 ${profile.color}`} />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white mb-1">
|
||||
{profile.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{profile.description}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{profile.features.map((feature, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs text-gray-600 dark:text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Initial Value (Optional) */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<label className="block mb-2 font-medium text-gray-900 dark:text-white">
|
||||
Valor Inicial (Opcional)
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Indica el valor inicial para simular la distribución
|
||||
</p>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={initialValue}
|
||||
onChange={(e) => setInitialValue(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full pl-8 pr-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/portfolio')}
|
||||
className="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedProfile}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
'Crear Portfolio'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
src/modules/portfolio/pages/PortfolioDashboard.tsx
Normal file
474
src/modules/portfolio/pages/PortfolioDashboard.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Portfolio Dashboard Page
|
||||
* Main dashboard for portfolio management with allocations, rebalancing, and goals
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
PlusIcon,
|
||||
ArrowPathIcon,
|
||||
ChartPieIcon,
|
||||
FlagIcon,
|
||||
CurrencyDollarIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import {
|
||||
getUserPortfolios,
|
||||
getPortfolioStats,
|
||||
getRebalanceRecommendations,
|
||||
executeRebalance,
|
||||
getUserGoals,
|
||||
type Portfolio,
|
||||
type PortfolioStats,
|
||||
type RebalanceRecommendation,
|
||||
type PortfolioGoal,
|
||||
} from '../../../services/portfolio.service';
|
||||
import { AllocationChart } from '../components/AllocationChart';
|
||||
import { AllocationTable } from '../components/AllocationTable';
|
||||
import { RebalanceCard } from '../components/RebalanceCard';
|
||||
import { GoalCard } from '../components/GoalCard';
|
||||
|
||||
// ============================================================================
|
||||
// Subcomponents
|
||||
// ============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
change?: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ label, value, change, icon, color }) => {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={`p-2 rounded-lg ${color}`}>{icon}</div>
|
||||
{change !== undefined && (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-sm font-medium ${
|
||||
change >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4" />
|
||||
)}
|
||||
{Math.abs(change).toFixed(2)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PortfolioDashboard() {
|
||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
||||
const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | null>(null);
|
||||
const [stats, setStats] = useState<PortfolioStats | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<RebalanceRecommendation[]>([]);
|
||||
const [goals, setGoals] = useState<PortfolioGoal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRebalancing, setIsRebalancing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'goals'>('overview');
|
||||
|
||||
// Fetch all data
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [portfolioData, goalsData] = await Promise.all([
|
||||
getUserPortfolios(),
|
||||
getUserGoals(),
|
||||
]);
|
||||
|
||||
setPortfolios(portfolioData);
|
||||
setGoals(goalsData);
|
||||
|
||||
if (portfolioData.length > 0) {
|
||||
const primary = portfolioData[0];
|
||||
setSelectedPortfolio(primary);
|
||||
|
||||
const [statsData, rebalanceData] = await Promise.all([
|
||||
getPortfolioStats(primary.id),
|
||||
getRebalanceRecommendations(primary.id),
|
||||
]);
|
||||
|
||||
setStats(statsData);
|
||||
setRecommendations(rebalanceData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error loading data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Handle portfolio selection
|
||||
const handlePortfolioSelect = async (portfolio: Portfolio) => {
|
||||
setSelectedPortfolio(portfolio);
|
||||
try {
|
||||
const [statsData, rebalanceData] = await Promise.all([
|
||||
getPortfolioStats(portfolio.id),
|
||||
getRebalanceRecommendations(portfolio.id),
|
||||
]);
|
||||
setStats(statsData);
|
||||
setRecommendations(rebalanceData);
|
||||
} catch (err) {
|
||||
console.error('Error loading portfolio data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle rebalance execution
|
||||
const handleRebalance = async () => {
|
||||
if (!selectedPortfolio) return;
|
||||
|
||||
try {
|
||||
setIsRebalancing(true);
|
||||
await executeRebalance(selectedPortfolio.id);
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
console.error('Error executing rebalance:', err);
|
||||
} finally {
|
||||
setIsRebalancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle goal deletion
|
||||
const handleDeleteGoal = async (goalId: string) => {
|
||||
// TODO: Implement delete confirmation modal
|
||||
console.log('Delete goal:', goalId);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-96">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Portfolio Manager
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Gestiona tus activos y alcanza tus metas financieras
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="p-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<Link
|
||||
to="/portfolio/new"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
Nuevo Portfolio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Selector (if multiple) */}
|
||||
{portfolios.length > 1 && (
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{portfolios.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handlePortfolioSelect(p)}
|
||||
className={`px-4 py-2 rounded-lg whitespace-nowrap transition-colors ${
|
||||
selectedPortfolio?.id === p.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'overview'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<ChartPieIcon className="w-5 h-5" />
|
||||
Resumen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('goals')}
|
||||
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'goals'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FlagIcon className="w-5 h-5" />
|
||||
Metas ({goals.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && selectedPortfolio && (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Valor Total"
|
||||
value={`$${selectedPortfolio.totalValue.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
icon={<CurrencyDollarIcon className="w-5 h-5 text-blue-600" />}
|
||||
color="bg-blue-100 dark:bg-blue-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="P&L No Realizado"
|
||||
value={`${selectedPortfolio.unrealizedPnl >= 0 ? '+' : ''}$${Math.abs(
|
||||
selectedPortfolio.unrealizedPnl
|
||||
).toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
change={selectedPortfolio.unrealizedPnlPercent}
|
||||
icon={<ArrowTrendingUpIcon className="w-5 h-5 text-green-600" />}
|
||||
color="bg-green-100 dark:bg-green-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cambio Hoy"
|
||||
value={stats ? `$${stats.dayChange.toLocaleString()}` : '-'}
|
||||
change={stats?.dayChangePercent}
|
||||
icon={<ChartPieIcon className="w-5 h-5 text-purple-600" />}
|
||||
color="bg-purple-100 dark:bg-purple-900/30"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cambio Mensual"
|
||||
value={stats ? `$${stats.monthChange.toLocaleString()}` : '-'}
|
||||
change={stats?.monthChangePercent}
|
||||
icon={<FlagIcon className="w-5 h-5 text-orange-600" />}
|
||||
color="bg-orange-100 dark:bg-orange-900/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Left: Allocation Chart & Table */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Chart Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-6">
|
||||
Distribución de Activos
|
||||
</h2>
|
||||
<AllocationChart
|
||||
allocations={selectedPortfolio.allocations}
|
||||
size={220}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Detalle de Posiciones
|
||||
</h2>
|
||||
<Link
|
||||
to={`/portfolio/${selectedPortfolio.id}/edit`}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Editar Allocaciones
|
||||
</Link>
|
||||
</div>
|
||||
<AllocationTable allocations={selectedPortfolio.allocations} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Rebalance & Performance */}
|
||||
<div className="space-y-6">
|
||||
<RebalanceCard
|
||||
recommendations={recommendations}
|
||||
onExecute={handleRebalance}
|
||||
isExecuting={isRebalancing}
|
||||
/>
|
||||
|
||||
{/* Best/Worst Performers */}
|
||||
{stats && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white mb-4">
|
||||
Rendimiento
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🏆</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Mejor: {stats.bestPerformer.asset}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-green-600 font-medium">
|
||||
+{stats.bestPerformer.change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📉</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
Peor: {stats.worstPerformer.asset}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-red-600 font-medium">
|
||||
{stats.worstPerformer.change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white mb-4">
|
||||
Información
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Perfil de Riesgo
|
||||
</span>
|
||||
<span className="text-gray-900 dark:text-white capitalize">
|
||||
{selectedPortfolio.riskProfile === 'conservative'
|
||||
? 'Conservador'
|
||||
: selectedPortfolio.riskProfile === 'moderate'
|
||||
? 'Moderado'
|
||||
: 'Agresivo'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Último Rebalanceo
|
||||
</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{selectedPortfolio.lastRebalanced
|
||||
? new Date(selectedPortfolio.lastRebalanced).toLocaleDateString()
|
||||
: 'Nunca'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Activos
|
||||
</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{selectedPortfolio.allocations.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'goals' && (
|
||||
<>
|
||||
{/* Goals Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Establece y monitorea tus metas financieras
|
||||
</p>
|
||||
<Link
|
||||
to="/portfolio/goals/new"
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
Nueva Meta
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{goals.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
|
||||
<span className="text-6xl mb-4 block">🎯</span>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
No tienes metas definidas
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Establece tus objetivos financieros y monitorea tu progreso
|
||||
</p>
|
||||
<Link
|
||||
to="/portfolio/goals/new"
|
||||
className="inline-block px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Crear Primera Meta
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{goals.map((goal) => (
|
||||
<GoalCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onDelete={handleDeleteGoal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State for no portfolios */}
|
||||
{portfolios.length === 0 && (
|
||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
|
||||
<span className="text-6xl mb-4 block">📊</span>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Crea tu primer Portfolio
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Diversifica tus inversiones según tu perfil de riesgo
|
||||
</p>
|
||||
<Link
|
||||
to="/portfolio/new"
|
||||
className="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Crear Portfolio
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
src/services/portfolio.service.ts
Normal file
254
src/services/portfolio.service.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Portfolio Service
|
||||
* Client for connecting to the Portfolio API
|
||||
*/
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type RiskProfile = 'conservative' | 'moderate' | 'aggressive';
|
||||
|
||||
export interface Portfolio {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
riskProfile: RiskProfile;
|
||||
allocations: PortfolioAllocation[];
|
||||
totalValue: number;
|
||||
totalCost: number;
|
||||
unrealizedPnl: number;
|
||||
unrealizedPnlPercent: number;
|
||||
realizedPnl: number;
|
||||
lastRebalanced: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortfolioAllocation {
|
||||
id: string;
|
||||
portfolioId: string;
|
||||
asset: string;
|
||||
targetPercent: number;
|
||||
currentPercent: number;
|
||||
quantity: number;
|
||||
value: number;
|
||||
cost: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
deviation: number;
|
||||
}
|
||||
|
||||
export interface PortfolioGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
targetAmount: number;
|
||||
currentAmount: number;
|
||||
targetDate: string;
|
||||
monthlyContribution: number;
|
||||
progress: number;
|
||||
projectedCompletion: string | null;
|
||||
status: 'on_track' | 'at_risk' | 'behind';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RebalanceRecommendation {
|
||||
asset: string;
|
||||
currentPercent: number;
|
||||
targetPercent: number;
|
||||
action: 'buy' | 'sell' | 'hold';
|
||||
amount: number;
|
||||
amountUSD: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface PortfolioStats {
|
||||
totalValue: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
weekChange: number;
|
||||
weekChangePercent: number;
|
||||
monthChange: number;
|
||||
monthChangePercent: number;
|
||||
allTimeChange: number;
|
||||
allTimeChangePercent: number;
|
||||
bestPerformer: { asset: string; change: number };
|
||||
worstPerformer: { asset: string; change: number };
|
||||
}
|
||||
|
||||
export interface CreatePortfolioInput {
|
||||
name: string;
|
||||
riskProfile: RiskProfile;
|
||||
initialValue?: number;
|
||||
}
|
||||
|
||||
export interface CreateGoalInput {
|
||||
name: string;
|
||||
targetAmount: number;
|
||||
targetDate: string;
|
||||
monthlyContribution: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Portfolio API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user's portfolios
|
||||
*/
|
||||
export async function getUserPortfolios(): Promise<Portfolio[]> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch portfolios');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portfolio by ID
|
||||
*/
|
||||
export async function getPortfolio(portfolioId: string): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch portfolio');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new portfolio
|
||||
*/
|
||||
export async function createPortfolio(input: CreatePortfolioInput): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create portfolio');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update portfolio allocations
|
||||
*/
|
||||
export async function updateAllocations(
|
||||
portfolioId: string,
|
||||
allocations: { asset: string; targetPercent: number }[]
|
||||
): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/allocations`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ allocations }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update allocations');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rebalancing recommendations
|
||||
*/
|
||||
export async function getRebalanceRecommendations(
|
||||
portfolioId: string
|
||||
): Promise<RebalanceRecommendation[]> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch recommendations');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute rebalancing
|
||||
*/
|
||||
export async function executeRebalance(portfolioId: string): Promise<Portfolio> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to execute rebalance');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portfolio statistics
|
||||
*/
|
||||
export async function getPortfolioStats(portfolioId: string): Promise<PortfolioStats> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/stats`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch stats');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Goals API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user's goals
|
||||
*/
|
||||
export async function getUserGoals(): Promise<PortfolioGoal[]> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/goals`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch goals');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new goal
|
||||
*/
|
||||
export async function createGoal(input: CreateGoalInput): Promise<PortfolioGoal> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/goals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create goal');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update goal progress
|
||||
*/
|
||||
export async function updateGoalProgress(
|
||||
goalId: string,
|
||||
currentAmount: number
|
||||
): Promise<PortfolioGoal> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ currentAmount }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update goal');
|
||||
const data = await response.json();
|
||||
return data.data || data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a goal
|
||||
*/
|
||||
export async function deleteGoal(goalId: string): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete goal');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user