[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 Settings = lazy(() => import('./modules/settings/pages/Settings'));
|
||||||
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
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
|
// Lazy load modules - Education
|
||||||
const Courses = lazy(() => import('./modules/education/pages/Courses'));
|
const Courses = lazy(() => import('./modules/education/pages/Courses'));
|
||||||
const CourseDetail = lazy(() => import('./modules/education/pages/CourseDetail'));
|
const CourseDetail = lazy(() => import('./modules/education/pages/CourseDetail'));
|
||||||
@ -82,6 +87,11 @@ function App() {
|
|||||||
<Route path="/backtesting" element={<BacktestingDashboard />} />
|
<Route path="/backtesting" element={<BacktestingDashboard />} />
|
||||||
<Route path="/investment" element={<Investment />} />
|
<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 */}
|
{/* Education */}
|
||||||
<Route path="/education/courses" element={<Courses />} />
|
<Route path="/education/courses" element={<Courses />} />
|
||||||
<Route path="/education/courses/:slug" element={<CourseDetail />} />
|
<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