[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:
Adrian Flores Cortes 2026-01-25 08:31:26 -06:00
parent b7de2a3d58
commit b8a7cbe691
9 changed files with 1894 additions and 0 deletions

View File

@ -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 />} />

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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');
}