From b8a7cbe691a747e023d2a56abf0ad723833e69db Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 08:31:26 -0600 Subject: [PATCH] [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 --- src/App.tsx | 10 + .../portfolio/components/AllocationChart.tsx | 137 +++++ .../portfolio/components/AllocationTable.tsx | 156 ++++++ src/modules/portfolio/components/GoalCard.tsx | 168 +++++++ .../portfolio/components/RebalanceCard.tsx | 148 ++++++ src/modules/portfolio/pages/CreateGoal.tsx | 307 ++++++++++++ .../portfolio/pages/CreatePortfolio.tsx | 240 +++++++++ .../portfolio/pages/PortfolioDashboard.tsx | 474 ++++++++++++++++++ src/services/portfolio.service.ts | 254 ++++++++++ 9 files changed, 1894 insertions(+) create mode 100644 src/modules/portfolio/components/AllocationChart.tsx create mode 100644 src/modules/portfolio/components/AllocationTable.tsx create mode 100644 src/modules/portfolio/components/GoalCard.tsx create mode 100644 src/modules/portfolio/components/RebalanceCard.tsx create mode 100644 src/modules/portfolio/pages/CreateGoal.tsx create mode 100644 src/modules/portfolio/pages/CreatePortfolio.tsx create mode 100644 src/modules/portfolio/pages/PortfolioDashboard.tsx create mode 100644 src/services/portfolio.service.ts diff --git a/src/App.tsx b/src/App.tsx index 8fae29b..0244481 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,11 @@ const Investment = lazy(() => import('./modules/investment/pages/Investment')); const Settings = lazy(() => import('./modules/settings/pages/Settings')); const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); +// Lazy load modules - Portfolio +const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard')); +const CreatePortfolio = lazy(() => import('./modules/portfolio/pages/CreatePortfolio')); +const CreateGoal = lazy(() => import('./modules/portfolio/pages/CreateGoal')); + // Lazy load modules - Education const Courses = lazy(() => import('./modules/education/pages/Courses')); const CourseDetail = lazy(() => import('./modules/education/pages/CourseDetail')); @@ -82,6 +87,11 @@ function App() { } /> } /> + {/* Portfolio Manager */} + } /> + } /> + } /> + {/* Education */} } /> } /> diff --git a/src/modules/portfolio/components/AllocationChart.tsx b/src/modules/portfolio/components/AllocationChart.tsx new file mode 100644 index 0000000..44e4bdd --- /dev/null +++ b/src/modules/portfolio/components/AllocationChart.tsx @@ -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 = { + 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 = ({ + 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 ( +
+ + {segments.map((segment, index) => ( + + + {segment.asset}: {segment.currentPercent.toFixed(1)}% ($ + {segment.value.toLocaleString()}) + + + ))} + {/* Center text */} + + Valor Total + + + ${totalValue.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + + {/* Legend */} +
+ {allocations.map((alloc) => ( +
+
+ + {alloc.asset} ({alloc.currentPercent.toFixed(1)}%) + +
+ ))} +
+
+ ); +}; + +export default AllocationChart; diff --git a/src/modules/portfolio/components/AllocationTable.tsx b/src/modules/portfolio/components/AllocationTable.tsx new file mode 100644 index 0000000..637aef3 --- /dev/null +++ b/src/modules/portfolio/components/AllocationTable.tsx @@ -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 = { + 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 = ({ + allocations, + showDeviation = true, +}) => { + return ( +
+ + + + + + + + + {showDeviation && ( + + )} + + + + + {allocations.map((alloc) => ( + + + + + + + {showDeviation && ( + + )} + + + ))} + +
+ Activo + + Cantidad + + Valor + + % Actual + + % Objetivo + + Desviación + + P&L +
+
+ {ASSET_ICONS[alloc.asset] ? ( + {alloc.asset} + ) : ( +
+ {alloc.asset.slice(0, 2)} +
+ )} + + {alloc.asset} + +
+
+ {alloc.quantity.toLocaleString(undefined, { + minimumFractionDigits: 4, + maximumFractionDigits: 8, + })} + + ${alloc.value.toLocaleString(undefined, { minimumFractionDigits: 2 })} + + {alloc.currentPercent.toFixed(1)}% + + {alloc.targetPercent.toFixed(1)}% + +
+ {Math.abs(alloc.deviation) < 1 ? ( + + ) : alloc.deviation > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-green-500' + : 'text-red-500' + }`} + > + {alloc.deviation > 0 ? '+' : ''} + {alloc.deviation.toFixed(1)}% + +
+
+
+ = 0 ? 'text-green-500' : 'text-red-500' + }`} + > + {alloc.pnl >= 0 ? '+' : ''}${alloc.pnl.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + = 0 ? 'text-green-500' : 'text-red-500' + }`} + > + ({alloc.pnlPercent >= 0 ? '+' : ''} + {alloc.pnlPercent.toFixed(2)}%) + +
+
+
+ ); +}; + +export default AllocationTable; diff --git a/src/modules/portfolio/components/GoalCard.tsx b/src/modules/portfolio/components/GoalCard.tsx new file mode 100644 index 0000000..0632df2 --- /dev/null +++ b/src/modules/portfolio/components/GoalCard.tsx @@ -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 = ({ + 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 ( +
+ {/* Header */} +
+
+
+ +
+
+

{goal.name}

+ {status.label} +
+
+ {onDelete && ( + + )} +
+ + {/* Progress bar */} +
+
+ Progreso + + {progressPercent.toFixed(1)}% + +
+
+
+
+
+ + {/* Stats */} +
+
+ +
+

Actual

+

+ ${goal.currentAmount.toLocaleString()} +

+
+
+
+ +
+

Meta

+

+ ${goal.targetAmount.toLocaleString()} +

+
+
+
+ + {/* Footer info */} +
+
+ Fecha objetivo + + {new Date(goal.targetDate).toLocaleDateString()} + +
+
+ Tiempo restante + + {monthsRemaining > 0 ? `${monthsRemaining} meses` : 'Vencido'} + +
+
+ Aporte mensual + + ${goal.monthlyContribution.toLocaleString()} + +
+ {goal.projectedCompletion && ( +
+ Proyección + + {new Date(goal.projectedCompletion).toLocaleDateString()} + +
+ )} +
+ + {/* Action button */} + {onUpdateProgress && ( + + )} +
+ ); +}; + +export default GoalCard; diff --git a/src/modules/portfolio/components/RebalanceCard.tsx b/src/modules/portfolio/components/RebalanceCard.tsx new file mode 100644 index 0000000..8708fb8 --- /dev/null +++ b/src/modules/portfolio/components/RebalanceCard.tsx @@ -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 = ({ + recommendations, + onExecute, + isExecuting = false, +}) => { + const hasRecommendations = recommendations.some((r) => r.action !== 'hold'); + const highPriorityCount = recommendations.filter((r) => r.priority === 'high').length; + + return ( +
+
+
+
+ ⚖️ +
+
+

Rebalanceo

+

+ {hasRecommendations + ? `${highPriorityCount} acción(es) prioritaria(s)` + : 'Portfolio balanceado'} +

+
+
+ {hasRecommendations && onExecute && ( + + )} +
+ + {!hasRecommendations ? ( +
+ +

+ Tu portfolio está bien balanceado +

+

+ No se requieren ajustes en este momento +

+
+ ) : ( +
+ {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) => ( +
+
+ {rec.action === 'buy' ? ( +
+ +
+ ) : rec.action === 'sell' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {rec.asset} + + {rec.priority === 'high' && ( + + )} +
+

+ {rec.currentPercent.toFixed(1)}% → {rec.targetPercent.toFixed(1)}% +

+
+
+
+

+ {rec.action === 'buy' ? 'Comprar' : 'Vender'} +

+

+ ~${rec.amountUSD.toLocaleString(undefined, { maximumFractionDigits: 0 })} +

+
+
+ ))} +
+ )} + + {hasRecommendations && ( +
+

+ 💡 Se recomienda rebalancear cuando la desviación supera el 5% del objetivo +

+
+ )} +
+ ); +}; + +export default RebalanceCard; diff --git a/src/modules/portfolio/pages/CreateGoal.tsx b/src/modules/portfolio/pages/CreateGoal.tsx new file mode 100644 index 0000000..1db792a --- /dev/null +++ b/src/modules/portfolio/pages/CreateGoal.tsx @@ -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(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 ( +
+ {/* Header */} +
+ +

+ Nueva Meta Financiera +

+

+ Establece un objetivo y monitorea tu progreso +

+
+ + {/* Preset Goals */} +
+ +
+ {GOAL_PRESETS.map((preset) => ( + + ))} +
+
+ +
+ {/* Goal Name */} +
+ + 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" + /> +
+ + {/* Target Amount & Date */} +
+
+ +
+ + $ + + 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" + /> +
+
+ +
+ + 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" + /> +
+
+ + {/* Monthly Contribution */} +
+ + {suggestedContribution && ( +

+ Sugerido: ${suggestedContribution}/mes para alcanzar tu meta +

+ )} +
+ + $ + + 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" + /> +
+ {suggestedContribution && !monthlyContribution && ( + + )} +
+ + {/* Summary Preview */} + {name && targetAmount && targetDate && ( +
+

+ Resumen de tu Meta +

+
+
+ Meta + + {name} + +
+
+ Objetivo + + ${parseFloat(targetAmount).toLocaleString()} + +
+
+ Fecha + + {new Date(targetDate).toLocaleDateString()} + +
+
+ + Aporte mensual + + + ${monthlyContribution || suggestedContribution || '0'}/mes + +
+
+
+ )} + + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Submit Button */} +
+ + +
+
+
+ ); +} diff --git a/src/modules/portfolio/pages/CreatePortfolio.tsx b/src/modules/portfolio/pages/CreatePortfolio.tsx new file mode 100644 index 0000000..bd82c0e --- /dev/null +++ b/src/modules/portfolio/pages/CreatePortfolio.tsx @@ -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(null); + const [initialValue, setInitialValue] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+ +

+ Crear Nuevo Portfolio +

+

+ Configura tu portfolio según tu perfil de riesgo +

+
+ +
+ {/* Portfolio Name */} +
+ + 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" + /> +
+ + {/* Risk Profile Selection */} +
+ +
+ {RISK_PROFILES.map((profile) => { + const Icon = profile.icon; + const isSelected = selectedProfile === profile.id; + + return ( + + ); + })} +
+
+ + {/* Initial Value (Optional) */} +
+ +

+ Indica el valor inicial para simular la distribución +

+
+ + $ + + 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" + /> +
+
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Submit Button */} +
+ + +
+
+
+ ); +} diff --git a/src/modules/portfolio/pages/PortfolioDashboard.tsx b/src/modules/portfolio/pages/PortfolioDashboard.tsx new file mode 100644 index 0000000..94015d5 --- /dev/null +++ b/src/modules/portfolio/pages/PortfolioDashboard.tsx @@ -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 = ({ label, value, change, icon, color }) => { + return ( +
+
+
{icon}
+ {change !== undefined && ( +
= 0 ? 'text-green-500' : 'text-red-500' + }`} + > + {change >= 0 ? ( + + ) : ( + + )} + {Math.abs(change).toFixed(2)}% +
+ )} +
+

{label}

+

{value}

+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function PortfolioDashboard() { + const [portfolios, setPortfolios] = useState([]); + const [selectedPortfolio, setSelectedPortfolio] = useState(null); + const [stats, setStats] = useState(null); + const [recommendations, setRecommendations] = useState([]); + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Portfolio Manager +

+

+ Gestiona tus activos y alcanza tus metas financieras +

+
+
+ + + + Nuevo Portfolio + +
+
+ + {/* Portfolio Selector (if multiple) */} + {portfolios.length > 1 && ( +
+ {portfolios.map((p) => ( + + ))} +
+ )} + + {/* Tabs */} +
+ + +
+ + {activeTab === 'overview' && selectedPortfolio && ( + <> + {/* Stats Grid */} +
+ } + color="bg-blue-100 dark:bg-blue-900/30" + /> + = 0 ? '+' : ''}$${Math.abs( + selectedPortfolio.unrealizedPnl + ).toLocaleString(undefined, { minimumFractionDigits: 2 })}`} + change={selectedPortfolio.unrealizedPnlPercent} + icon={} + color="bg-green-100 dark:bg-green-900/30" + /> + } + color="bg-purple-100 dark:bg-purple-900/30" + /> + } + color="bg-orange-100 dark:bg-orange-900/30" + /> +
+ + {/* Main Content Grid */} +
+ {/* Left: Allocation Chart & Table */} +
+ {/* Chart Card */} +
+

+ Distribución de Activos +

+ +
+ + {/* Table Card */} +
+
+

+ Detalle de Posiciones +

+ + Editar Allocaciones + +
+ +
+
+ + {/* Right: Rebalance & Performance */} +
+ + + {/* Best/Worst Performers */} + {stats && ( +
+

+ Rendimiento +

+
+
+
+ 🏆 + + Mejor: {stats.bestPerformer.asset} + +
+ + +{stats.bestPerformer.change.toFixed(2)}% + +
+
+
+ 📉 + + Peor: {stats.worstPerformer.asset} + +
+ + {stats.worstPerformer.change.toFixed(2)}% + +
+
+
+ )} + + {/* Quick Info */} +
+

+ Información +

+
+
+ + Perfil de Riesgo + + + {selectedPortfolio.riskProfile === 'conservative' + ? 'Conservador' + : selectedPortfolio.riskProfile === 'moderate' + ? 'Moderado' + : 'Agresivo'} + +
+
+ + Último Rebalanceo + + + {selectedPortfolio.lastRebalanced + ? new Date(selectedPortfolio.lastRebalanced).toLocaleDateString() + : 'Nunca'} + +
+
+ + Activos + + + {selectedPortfolio.allocations.length} + +
+
+
+
+
+ + )} + + {activeTab === 'goals' && ( + <> + {/* Goals Header */} +
+

+ Establece y monitorea tus metas financieras +

+ + + Nueva Meta + +
+ + {goals.length === 0 ? ( +
+ 🎯 +

+ No tienes metas definidas +

+

+ Establece tus objetivos financieros y monitorea tu progreso +

+ + Crear Primera Meta + +
+ ) : ( +
+ {goals.map((goal) => ( + + ))} +
+ )} + + )} + + {/* Empty State for no portfolios */} + {portfolios.length === 0 && ( +
+ 📊 +

+ Crea tu primer Portfolio +

+

+ Diversifica tus inversiones según tu perfil de riesgo +

+ + Crear Portfolio + +
+ )} +
+ ); +} diff --git a/src/services/portfolio.service.ts b/src/services/portfolio.service.ts new file mode 100644 index 0000000..f09ad38 --- /dev/null +++ b/src/services/portfolio.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); +}